Merge branch 'master' into TwitchAlerts

This commit is contained in:
Ben Arc 2021-07-07 09:57:25 +01:00
commit dd5080ac5a
161 changed files with 4145 additions and 1308 deletions

View file

@ -5,14 +5,29 @@ QUART_DEBUG=true
HOST=127.0.0.1 HOST=127.0.0.1
PORT=5000 PORT=5000
LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS="" LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data" LNBITS_DATA_FOLDER="./data"
LNBITS_DISABLED_EXTENSIONS="amilk" # LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
LNBITS_DISABLED_EXTENSIONS="amilk,ngrok"
LNBITS_FORCE_HTTPS=true LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0" LNBITS_SERVICE_FEE="0.0"
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="mint, flamingo, classic, autumn, monochrome, salvador"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet # LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ __pycache__
*$py.class *$py.class
.mypy_cache .mypy_cache
.vscode .vscode
*-lock.json
*.egg *.egg
*.egg-info *.egg-info

View file

@ -17,7 +17,6 @@ shortuuid = "*"
quart = "*" quart = "*"
quart-cors = "*" quart-cors = "*"
quart-compress = "*" quart-compress = "*"
secure = "*"
typing-extensions = "*" typing-extensions = "*"
httpx = "*" httpx = "*"
quart-trio = "*" quart-trio = "*"
@ -35,3 +34,4 @@ pytest = "*"
pytest-cov = "*" pytest-cov = "*"
mypy = "latest" mypy = "latest"
pytest-trio = "*" pytest-trio = "*"
trio-typing = "*"

405
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "e12af74353e8bea3f97bf2aea16a1ba0a6e4c3a08042ce7368187a06e7791e2c" "sha256": "4067e94f45066ab088fc12ce09371b360c2bdb6b29f10c84f8ca06b3a9ede22a"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -18,10 +18,19 @@
"default": { "default": {
"aiofiles": { "aiofiles": {
"hashes": [ "hashes": [
"sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27", "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4",
"sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092" "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"
], ],
"version": "==0.6.0" "markers": "python_version >= '3.6' and python_version < '4.0'",
"version": "==0.7.0"
},
"anyio": {
"hashes": [
"sha256:41c4be842c284222b197a625d76a7ab85adf9d52788f563172fe180c2744b6c1",
"sha256:89e19b1498c8a6f12277e0bd2949597e445aa1b14361fbab2c36943639ef5190"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.2.0"
}, },
"async-generator": { "async-generator": {
"hashes": [ "hashes": [
@ -33,11 +42,11 @@
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==20.3.0" "version": "==21.2.0"
}, },
"bech32": { "bech32": {
"hashes": [ "hashes": [
@ -99,41 +108,40 @@
}, },
"cerberus": { "cerberus": {
"hashes": [ "hashes": [
"sha256:7aff49bc793e58a88ac14bffc3eca0f67e077881d3c62c621679a621294dd174", "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
"sha256:eec10585c33044fb7c69650bc5b68018dac0443753337e2b07684ee0f3c83329"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.3.3" "version": "==1.3.4"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
], ],
"version": "==2020.12.5" "version": "==2021.5.30"
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '3.6'",
"version": "==7.1.2" "version": "==8.0.1"
}, },
"ecdsa": { "ecdsa": {
"hashes": [ "hashes": [
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747", "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676",
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff" "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.16.1" "version": "==0.17.0"
}, },
"embit": { "embit": {
"hashes": [ "hashes": [
"sha256:7c4264d7ede8e2c114db10585270874c9df809c68d2e21db918872e3245b5f2b" "sha256:d67fc0f7fbdb7588c3eb24441bf8e05770056260bc8e5537399a1b3ce5ccf12a"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.2.1" "version": "==0.4.2"
}, },
"environs": { "environs": {
"hashes": [ "hashes": [
@ -169,19 +177,19 @@
}, },
"httpcore": { "httpcore": {
"hashes": [ "hashes": [
"sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9", "sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e",
"sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc" "sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==0.12.3" "version": "==0.13.6"
}, },
"httpx": { "httpx": {
"hashes": [ "hashes": [
"sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967", "sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c",
"sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272" "sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.17.1" "version": "==0.18.2"
}, },
"hypercorn": { "hypercorn": {
"extras": [ "extras": [
@ -196,34 +204,34 @@
}, },
"hyperframe": { "hyperframe": {
"hashes": [ "hashes": [
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1", "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15",
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34" "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"
], ],
"markers": "python_full_version >= '3.6.1'", "markers": "python_full_version >= '3.6.1'",
"version": "==6.0.0" "version": "==6.0.1"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
], ],
"version": "==3.1" "version": "==3.2"
}, },
"itsdangerous": { "itsdangerous": {
"hashes": [ "hashes": [
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '3.6'",
"version": "==1.1.0" "version": "==2.0.1"
}, },
"jinja2": { "jinja2": {
"hashes": [ "hashes": [
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '3.6'",
"version": "==2.11.3" "version": "==3.0.1"
}, },
"lnurl": { "lnurl": {
"hashes": [ "hashes": [
@ -235,69 +243,51 @@
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '3.6'",
"version": "==1.1.1" "version": "==2.0.1"
}, },
"marshmallow": { "marshmallow": {
"hashes": [ "hashes": [
"sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd", "sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040",
"sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b" "sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01"
], ],
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==3.11.1" "version": "==3.12.1"
}, },
"outcome": { "outcome": {
"hashes": [ "hashes": [
@ -316,31 +306,31 @@
}, },
"pydantic": { "pydantic": {
"hashes": [ "hashes": [
"sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850", "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd",
"sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f", "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739",
"sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683", "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f",
"sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e", "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840",
"sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3", "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23",
"sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9", "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287",
"sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c", "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62",
"sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f", "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b",
"sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a", "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb",
"sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2", "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820",
"sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125", "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3",
"sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8", "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b",
"sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99", "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e",
"sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f", "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3",
"sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0", "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316",
"sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d", "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b",
"sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520", "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4",
"sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58", "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20",
"sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771", "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e",
"sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4", "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505",
"sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e", "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1",
"sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3" "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"
], ],
"markers": "python_full_version >= '3.6.1'", "markers": "python_full_version >= '3.6.1'",
"version": "==1.8.1" "version": "==1.8.2"
}, },
"pypng": { "pypng": {
"hashes": [ "hashes": [
@ -366,18 +356,18 @@
}, },
"python-dotenv": { "python-dotenv": {
"hashes": [ "hashes": [
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a", "sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d",
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2" "sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d"
], ],
"version": "==0.17.0" "version": "==0.18.0"
}, },
"quart": { "quart": {
"hashes": [ "hashes": [
"sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02", "sha256:f35134fb1d81af61624e6d89bca33cd611dcedce2dc4e291f527ab04395f4e1a",
"sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707" "sha256:f80c91d1e0588662483e22dd9c368a5778886b62e128c5399d2cc1b1898482cf"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.14.1" "version": "==0.15.1"
}, },
"quart-compress": { "quart-compress": {
"hashes": [ "hashes": [
@ -389,19 +379,19 @@
}, },
"quart-cors": { "quart-cors": {
"hashes": [ "hashes": [
"sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573", "sha256:c2be932f20413a56b176527090229afe8f725a3ee029d45ea08a174cdc319823",
"sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052" "sha256:ea08d26aef918d59194fbf065cde9b6cae90dc5f21120dcd254d7d46190cd293"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.4.0" "version": "==0.5.0"
}, },
"quart-trio": { "quart-trio": {
"hashes": [ "hashes": [
"sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a", "sha256:27617f0c9fa8759d3056e9ddcdc038d44093af45eb5f84f8d5714872aaaa8c7d",
"sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1" "sha256:30dfab5e382f06c605d4a5960e8188e8e05d10198f02097f0a16c1dca41b3574"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.7.0" "version": "==0.8.0"
}, },
"represent": { "represent": {
"hashes": [ "hashes": [
@ -416,18 +406,10 @@
"idna2008" "idna2008"
], ],
"hashes": [ "hashes": [
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
], ],
"version": "==1.4.0" "version": "==1.5.0"
},
"secure": {
"hashes": [
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
],
"index": "pypi",
"version": "==0.2.1"
}, },
"shortuuid": { "shortuuid": {
"hashes": [ "hashes": [
@ -439,11 +421,11 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.16.0"
}, },
"sniffio": { "sniffio": {
"hashes": [ "hashes": [
@ -455,10 +437,10 @@
}, },
"sortedcontainers": { "sortedcontainers": {
"hashes": [ "hashes": [
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1" "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
], ],
"version": "==2.3.0" "version": "==2.4.0"
}, },
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
@ -530,20 +512,20 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.7.4.3" "version": "==3.10.0.0"
}, },
"werkzeug": { "werkzeug": {
"hashes": [ "hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '3.6'",
"version": "==1.0.1" "version": "==2.0.1"
}, },
"wsproto": { "wsproto": {
"hashes": [ "hashes": [
@ -572,11 +554,11 @@
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==20.3.0" "version": "==21.2.0"
}, },
"black": { "black": {
"hashes": [ "hashes": [
@ -587,11 +569,11 @@
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '3.6'",
"version": "==7.1.2" "version": "==8.0.1"
}, },
"coverage": { "coverage": {
"hashes": [ "hashes": [
@ -653,10 +635,10 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
], ],
"version": "==3.1" "version": "==3.2"
}, },
"iniconfig": { "iniconfig": {
"hashes": [ "hashes": [
@ -667,31 +649,32 @@
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e", "sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2",
"sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064", "sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4",
"sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c", "sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8",
"sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4", "sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da",
"sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97", "sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243",
"sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df", "sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb",
"sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8", "sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116",
"sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a", "sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0",
"sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56", "sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76",
"sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7", "sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20",
"sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6", "sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c",
"sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5", "sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1",
"sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a", "sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab",
"sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521", "sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269",
"sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564", "sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2",
"sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49", "sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4",
"sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66", "sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70",
"sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a", "sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9",
"sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119", "sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd",
"sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506", "sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987",
"sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c", "sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21",
"sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb" "sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167",
"sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.812" "version": "==0.902"
}, },
"mypy-extensions": { "mypy-extensions": {
"hashes": [ "hashes": [
@ -749,19 +732,19 @@
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634", "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b",
"sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc" "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"
], ],
"index": "pypi", "index": "pypi",
"version": "==6.2.3" "version": "==6.2.4"
}, },
"pytest-cov": { "pytest-cov": {
"hashes": [ "hashes": [
"sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7", "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a",
"sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da" "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.11.1" "version": "==2.12.1"
}, },
"pytest-trio": { "pytest-trio": {
"hashes": [ "hashes": [
@ -826,10 +809,10 @@
}, },
"sortedcontainers": { "sortedcontainers": {
"hashes": [ "hashes": [
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1" "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
], ],
"version": "==2.3.0" "version": "==2.4.0"
}, },
"toml": { "toml": {
"hashes": [ "hashes": [
@ -847,6 +830,14 @@
"index": "pypi", "index": "pypi",
"version": "==0.16.0" "version": "==0.16.0"
}, },
"trio-typing": {
"hashes": [
"sha256:35f1bec8df2150feab6c8b073b54135321722c9d9289bbffa78a9a091ea83b72",
"sha256:f2007df617a6c26a2294db0dd63645b5451149757e1bde4cb8dbf3e1369174fb"
],
"index": "pypi",
"version": "==0.5.0"
},
"typed-ast": { "typed-ast": {
"hashes": [ "hashes": [
"sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace", "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
@ -884,12 +875,12 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.7.4.3" "version": "==3.10.0.0"
} }
} }
} }

View file

@ -40,6 +40,13 @@ Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Ne
## Running the server ## Running the server
LNbits uses [Quart][quart] as an application server. LNbits uses [Quart][quart] as an application server.
Before running the server for the first time, make sure to create the data folder:
```sh
$ mkdir data
```
To then run the server, use:
```sh ```sh
$ pipenv run python -m lnbits $ pipenv run python -m lnbits

View file

@ -23,11 +23,11 @@ mkdir data
./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' ./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
``` ```
No you can visit your LNbits at http://localhost:5000/. Now you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source. Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can run restart it and it will be using the new settings. Then you can restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source. You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
@ -37,7 +37,7 @@ Docker installation
To install using docker you first need to build the docker image as: To install using docker you first need to build the docker image as:
``` ```
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits/ # ${PWD} refered as <lnbits_repo> cd lnbits/ # ${PWD} referred as <lnbits_repo>
docker build -t lnbits . docker build -t lnbits .
``` ```
@ -57,4 +57,4 @@ Then the image can be run as:
``` ```
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
``` ```
Finally you can access the lnbits on your machine port 5000. Finally you can access your lnbits on your machine at port 5000.

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
from .commands import migrate_databases, transpile_scss, bundle_vendored from .commands import migrate_databases, transpile_scss, bundle_vendored

View file

@ -7,7 +7,6 @@ from quart import g
from quart_trio import QuartTrio from quart_trio import QuartTrio
from quart_cors import cors # type: ignore from quart_cors import cors # type: ignore
from quart_compress import Compress # type: ignore from quart_compress import Compress # type: ignore
from secure import SecureHeaders # type: ignore
from .commands import db_migrate, handle_assets from .commands import db_migrate, handle_assets
from .core import core_app from .core import core_app
@ -27,8 +26,6 @@ from .tasks import (
) )
from .settings import WALLET from .settings import WALLET
secure_headers = SecureHeaders(hsts=False, xfo=False)
def create_app(config_object="lnbits.settings") -> QuartTrio: def create_app(config_object="lnbits.settings") -> QuartTrio:
"""Create application factory. """Create application factory.
@ -46,7 +43,6 @@ def create_app(config_object="lnbits.settings") -> QuartTrio:
register_blueprints(app) register_blueprints(app)
register_filters(app) register_filters(app)
register_commands(app) register_commands(app)
register_request_hooks(app)
register_async_tasks(app) register_async_tasks(app)
register_exception_handlers(app) register_exception_handlers(app)
@ -108,19 +104,13 @@ def register_assets(app: QuartTrio):
def register_filters(app: QuartTrio): def register_filters(app: QuartTrio):
"""Jinja filters.""" """Jinja filters."""
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"] app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
app.jinja_env.globals["SITE_TAGLINE"] = app.config["LNBITS_SITE_TAGLINE"]
app.jinja_env.globals["SITE_DESCRIPTION"] = app.config["LNBITS_SITE_DESCRIPTION"]
app.jinja_env.globals["LNBITS_THEME_OPTIONS"] = app.config["LNBITS_THEME_OPTIONS"]
app.jinja_env.globals["LNBITS_VERSION"] = app.config["LNBITS_COMMIT"] app.jinja_env.globals["LNBITS_VERSION"] = app.config["LNBITS_COMMIT"]
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions() app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
def register_request_hooks(app: QuartTrio):
"""Open the core db for each request so everything happens in a big transaction"""
@app.after_request
async def set_secure_headers(response):
secure_headers.quart(response)
return response
def register_async_tasks(app): def register_async_tasks(app):
@app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) @app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def webhook_listener(): async def webhook_listener():

View file

@ -161,9 +161,9 @@ def _trim_to_bytes(barr):
def _readable_scid(short_channel_id: int) -> str: def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format( return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xFFFFFF), blockheight=((short_channel_id >> 40) & 0xffffff),
transactionindex=((short_channel_id >> 16) & 0xFFFFFF), transactionindex=((short_channel_id >> 16) & 0xffffff),
outputindex=(short_channel_id & 0xFFFF), outputindex=(short_channel_id & 0xffff),
) )

View file

@ -1,11 +1,11 @@
import trio # type: ignore import trio
import warnings import warnings
import click import click
import importlib import importlib
import re import re
import os import os
from sqlalchemy.exc import OperationalError # type: ignore
from .db import SQLITE, POSTGRES, COCKROACH
from .core import db as core_db, migrations as core_migrations from .core import db as core_db, migrations as core_migrations
from .helpers import ( from .helpers import (
get_valid_extensions, get_valid_extensions,
@ -53,41 +53,59 @@ def bundle_vendored():
async def migrate_databases(): async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them.""" """Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn: async def set_migration_version(conn, db_name, version):
try: await conn.execute(
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() """
except OperationalError: INSERT INTO dbversions (db, version) VALUES (?, ?)
# migration 3 wasn't ran ON CONFLICT (db) DO UPDATE SET version = ?
await core_migrations.m000_create_migrations_table(conn) """,
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() (db_name, version, version),
)
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
print(f"running migration {db_name}.{version}")
await migrate(db)
if db.schema == None:
await set_migration_version(db, db_name, version)
else:
async with core_db.connect() as conn:
await set_migration_version(conn, db_name, version)
async with core_db.connect() as conn:
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
current_versions = {row["db"]: row["version"] for row in rows} current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_") matcher = re.compile(r"^m(\d\d\d)_")
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
print(f"running migration {db_name}.{version}")
await migrate(db)
await conn.execute(
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)",
(db_name, version),
)
await run_migration(conn, core_migrations) await run_migration(conn, core_migrations)
for ext in get_valid_extensions(): for ext in get_valid_extensions():
try: try:
ext_migrations = importlib.import_module( ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations" f"lnbits.extensions.{ext.code}.migrations"
) )
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
await run_migration(ext_db, ext_migrations) except ImportError:
except ImportError: raise ImportError(
raise ImportError( f"Please make sure that the extension `{ext.code}` has a migrations file."
f"Please make sure that the extension `{ext.code}` has a migrations file." )
)
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations)

View file

@ -5,7 +5,7 @@ from typing import List, Optional, Dict, Any
from urllib.parse import urlparse from urllib.parse import urlparse
from lnbits import bolt11 from lnbits import bolt11
from lnbits.db import Connection from lnbits.db import Connection, POSTGRES, COCKROACH
from lnbits.settings import DEFAULT_WALLET_NAME from lnbits.settings import DEFAULT_WALLET_NAME
from . import db from . import db
@ -43,13 +43,14 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
if user: if user:
extensions = await (conn or db).fetchall( extensions = await (conn or db).fetchall(
"SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,) """SELECT extension FROM extensions WHERE "user" = ? AND active""",
(user_id,),
) )
wallets = await (conn or db).fetchall( wallets = await (conn or db).fetchall(
""" """
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets FROM wallets
WHERE user = ? WHERE "user" = ?
""", """,
(user_id,), (user_id,),
) )
@ -70,14 +71,14 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
async def update_user_extension( async def update_user_extension(
*, user_id: str, extension: str, active: int, conn: Optional[Connection] = None *, user_id: str, extension: str, active: bool, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT OR REPLACE INTO extensions (user, extension, active) INSERT INTO extensions ("user", extension, active) VALUES (?, ?, ?)
VALUES (?, ?, ?) ON CONFLICT ("user", extension) DO UPDATE SET active = ?
""", """,
(user_id, extension, active), (user_id, extension, active, active),
) )
@ -94,7 +95,7 @@ async def create_wallet(
wallet_id = uuid4().hex wallet_id = uuid4().hex
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO wallets (id, name, user, adminkey, inkey) INSERT INTO wallets (id, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
( (
@ -119,10 +120,10 @@ async def delete_wallet(
""" """
UPDATE wallets AS w UPDATE wallets AS w
SET SET
user = 'del:' || w.user, "user" = 'del:' || w."user",
adminkey = 'del:' || w.adminkey, adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey inkey = 'del:' || w.inkey
WHERE id = ? AND user = ? WHERE id = ? AND "user" = ?
""", """,
(wallet_id, user_id), (wallet_id, user_id),
) )
@ -218,7 +219,12 @@ async def get_payments(
clause: List[str] = [] clause: List[str] = []
if since != None: if since != None:
clause.append("time > ?") if db.type == POSTGRES:
clause.append("time > to_timestamp(?)")
elif db.type == COCKROACH:
clause.append("time > cast(? AS timestamp)")
else:
clause.append("time > ?")
args.append(since) args.append(since)
if wallet_id: if wallet_id:
@ -228,9 +234,9 @@ async def get_payments(
if complete and pending: if complete and pending:
pass pass
elif complete: elif complete:
clause.append("((amount > 0 AND pending = 0) OR amount < 0)") clause.append("((amount > 0 AND pending = false) OR amount < 0)")
elif pending: elif pending:
clause.append("pending = 1") clause.append("pending = true")
else: else:
pass pass
@ -269,20 +275,21 @@ async def delete_expired_invoices(
) -> None: ) -> None:
# first we delete all invoices older than one month # first we delete all invoices older than one month
await (conn or db).execute( await (conn or db).execute(
""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 2592000 WHERE pending = true AND amount > 0
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
""" """
) )
# then we delete all expired invoices, checking one by one # then we delete all expired invoices, checking one by one
rows = await (conn or db).fetchall( rows = await (conn or db).fetchall(
""" f"""
SELECT bolt11 SELECT bolt11
FROM apipayments FROM apipayments
WHERE pending = 1 WHERE pending = true
AND bolt11 IS NOT NULL AND bolt11 IS NOT NULL
AND amount > 0 AND time < strftime('%s', 'now') - 86400 AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
""" """
) )
for (payment_request,) in rows: for (payment_request,) in rows:
@ -298,7 +305,7 @@ async def delete_expired_invoices(
await (conn or db).execute( await (conn or db).execute(
""" """
DELETE FROM apipayments DELETE FROM apipayments
WHERE pending = 1 AND hash = ? WHERE pending = true AND hash = ?
""", """,
(invoice.payment_hash,), (invoice.payment_hash,),
) )
@ -337,7 +344,7 @@ async def create_payment(
payment_hash, payment_hash,
preimage, preimage,
amount, amount,
int(pending), pending,
memo, memo,
fee, fee,
json.dumps(extra) json.dumps(extra)
@ -361,7 +368,7 @@ async def update_payment_status(
await (conn or db).execute( await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?", "UPDATE apipayments SET pending = ? WHERE checking_id = ?",
( (
int(pending), pending,
checking_id, checking_id,
), ),
) )
@ -406,10 +413,10 @@ async def save_balance_check(
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT OR REPLACE INTO balance_check (wallet, service, url) INSERT INTO balance_check (wallet, service, url) VALUES (?, ?, ?)
VALUES (?, ?, ?) ON CONFLICT (wallet, service) DO UPDATE SET url = ?
""", """,
(wallet_id, domain, url), (wallet_id, domain, url, url),
) )
@ -445,10 +452,10 @@ async def save_balance_notify(
): ):
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT OR REPLACE INTO balance_notify (wallet, url) INSERT INTO balance_notify (wallet, url) VALUES (?, ?)
VALUES (?, ?) ON CONFLICT (wallet) DO UPDATE SET url = ?
""", """,
(wallet_id, url), (wallet_id, url, url),
) )

View file

@ -18,7 +18,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT, email TEXT,
pass TEXT pass TEXT
@ -27,37 +27,36 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS extensions ( CREATE TABLE extensions (
user TEXT NOT NULL, "user" TEXT NOT NULL,
extension TEXT NOT NULL, extension TEXT NOT NULL,
active BOOLEAN DEFAULT 0, active BOOLEAN DEFAULT false,
UNIQUE (user, extension) UNIQUE ("user", extension)
); );
""" """
) )
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE wallets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
user TEXT NOT NULL, "user" TEXT NOT NULL,
adminkey TEXT NOT NULL, adminkey TEXT NOT NULL,
inkey TEXT inkey TEXT
); );
""" """
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE IF NOT EXISTS apipayments ( CREATE TABLE apipayments (
payhash TEXT NOT NULL, payhash TEXT NOT NULL,
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
fee INTEGER NOT NULL DEFAULT 0, fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL, pending BOOLEAN NOT NULL,
memo TEXT, memo TEXT,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')), time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE (wallet, payhash) UNIQUE (wallet, payhash)
); );
""" """
@ -65,18 +64,18 @@ async def m001_initial(db):
await db.execute( await db.execute(
""" """
CREATE VIEW IF NOT EXISTS balances AS CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM ( SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet GROUP BY wallet
UNION ALL UNION ALL
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
FROM apipayments FROM apipayments
WHERE amount < 0 -- do sum pending WHERE amount < 0 -- do sum pending
GROUP BY wallet GROUP BY wallet
) )x
GROUP BY wallet; GROUP BY wallet;
""" """
) )
@ -143,21 +142,20 @@ async def m004_ensure_fees_are_always_negative(db):
""" """
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute( await db.execute(
""" """
CREATE VIEW IF NOT EXISTS balances AS CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM ( SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet GROUP BY wallet
UNION ALL UNION ALL
SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees
FROM apipayments FROM apipayments
WHERE amount < 0 -- do sum pending WHERE amount < 0 -- do sum pending
GROUP BY wallet GROUP BY wallet
) )x
GROUP BY wallet; GROUP BY wallet;
""" """
) )
@ -171,7 +169,7 @@ async def m005_balance_check_balance_notify(db):
await db.execute( await db.execute(
""" """
CREATE TABLE balance_check ( CREATE TABLE balance_check (
wallet INTEGER NOT NULL REFERENCES wallets (id), wallet TEXT NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL, service TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
@ -183,7 +181,7 @@ async def m005_balance_check_balance_notify(db):
await db.execute( await db.execute(
""" """
CREATE TABLE balance_notify ( CREATE TABLE balance_notify (
wallet INTEGER NOT NULL REFERENCES wallets (id), wallet TEXT NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL, url TEXT NOT NULL,
UNIQUE(wallet, url) UNIQUE(wallet, url)

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
import json import json
import httpx import httpx
from io import BytesIO from io import BytesIO

View file

@ -202,9 +202,7 @@ new Vue({
return this.parse.invoice.sat <= this.balance return this.parse.invoice.sat <= this.balance
}, },
pendingPaymentsExist: function () { pendingPaymentsExist: function () {
return this.payments return this.payments.findIndex(payment => payment.pending) !== -1
? _.where(this.payments, {pending: 1}).length > 0
: false
} }
}, },
filters: { filters: {

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
import httpx import httpx
from typing import List from typing import List

View file

@ -17,14 +17,14 @@
></q-icon> ></q-icon>
{% raw %} {% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5> <h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }} {% endraw %} <small>{{ extension.shortDescription }} </small>{% endraw %}
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-actions> <q-card-actions>
<div v-if="extension.isEnabled"> <div v-if="extension.isEnabled">
<q-btn <q-btn
flat flat
color="deep-purple" color="primary"
type="a" type="a"
:href="[extension.url, '?usr=', g.user.id].join('')" :href="[extension.url, '?usr=', g.user.id].join('')"
>Open</q-btn >Open</q-btn
@ -41,7 +41,7 @@
<q-btn <q-btn
v-else v-else
flat flat
color="deep-purple" color="primary"
type="a" type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')" :href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
> >

View file

@ -8,7 +8,7 @@
{% if lnurl %} {% if lnurl %}
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
@click="processing" @click="processing"
type="a" type="a"
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}" href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
@ -25,7 +25,7 @@
></q-input> ></q-input>
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="walletName == ''" :disable="walletName == ''"
type="submit" type="submit"
>Add a new wallet</q-btn >Add a new wallet</q-btn
@ -37,58 +37,66 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3> <h3 class="q-my-none">{{SITE_TITLE}}</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5> <h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
<p> <div v-if="'{{SITE_TITLE}}' == 'LNbits'">
Easy to set up and lightweight, LNbits can run on any <p>
lightning-network funding source, currently supporting LND, Easy to set up and lightweight, LNbits can run on any
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself! lightning-network funding source, currently supporting LND,
</p> c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
<p> </p>
You can run LNbits for yourself, or easily offer a custodian solution <p>
for others. You can run LNbits for yourself, or easily offer a custodian
</p> solution for others.
<p> </p>
Each wallet has its own API keys and there is no limit to the number <p>
of wallets you can make. Being able to partition funds makes LNbits a Each wallet has its own API keys and there is no limit to the number
useful tool for money management and as a development tool. of wallets you can make. Being able to partition funds makes LNbits
</p> a useful tool for money management and as a development tool.
<p> </p>
Extensions add extra functionality to LNbits so you can experiment <p>
with a range of cutting-edge technologies on the lightning network. We Extensions add extra functionality to LNbits so you can experiment
have made developing extensions as easy as possible, and as a free and with a range of cutting-edge technologies on the lightning network.
open-source project, we encourage people to develop and submit their We have made developing extensions as easy as possible, and as a
own. free and open-source project, we encourage people to develop and
</p> submit their own.
<div class="row q-mt-md q-gutter-sm"> </p>
<q-btn <div class="row q-mt-md q-gutter-sm">
outline <q-btn
color="grey" outline
type="a" color="grey"
href="https://github.com/lnbits/lnbits" type="a"
target="_blank" href="https://github.com/lnbits/lnbits"
rel="noopener" target="_blank"
>View project in GitHub</q-btn rel="noopener"
> >View project in GitHub</q-btn
<q-btn >
outline <q-btn
color="grey" outline
type="a" color="grey"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK" type="a"
target="_blank" href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
rel="noopener" target="_blank"
>Donate</q-btn rel="noopener"
> >Donate</q-btn
>
</div>
</div> </div>
<p v-else>{{SITE_DESCRIPTION}}</p>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<!-- Ads --> <!-- Ads -->
<div class="col-12 col-md-3 col-lg-3"> <div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
<div class="row q-col-gutter-lg justify-center"> <div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm"> <div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="purple" label="Runs on" class="full-width"></q-btn> <q-btn
flat
color="secondary"
label="Runs on"
class="full-width"
></q-btn>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a href="https://github.com/ElementsProject/lightning"> <a href="https://github.com/ElementsProject/lightning">

View file

@ -22,7 +22,7 @@
<div class="col"> <div class="col">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
class="full-width" class="full-width"
@click="showParseDialog" @click="showParseDialog"
>Paste Request</q-btn >Paste Request</q-btn
@ -31,7 +31,7 @@
<div class="col"> <div class="col">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
class="full-width" class="full-width"
@click="showReceiveDialog" @click="showReceiveDialog"
>Create Invoice</q-btn >Create Invoice</q-btn
@ -40,7 +40,7 @@
<div class="col"> <div class="col">
<q-btn <q-btn
unelevated unelevated
color="purple" color="secondary"
icon="photo_camera" icon="photo_camera"
@click="showCamera" @click="showCamera"
>scan >scan
@ -222,7 +222,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm"> <h6 class="text-subtitle1 q-mt-none q-mb-sm">
LNbits wallet: <strong><em>{{ wallet.name }}</em></strong> {{ SITE_TITLE }} wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
@ -342,7 +342,7 @@
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0" :disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
type="submit" type="submit"
> >
@ -355,7 +355,7 @@
</div> </div>
<q-spinner <q-spinner
v-if="receive.status == 'loading'" v-if="receive.status == 'loading'"
color="deep-purple" color="primary"
size="2.55em" size="2.55em"
></q-spinner> ></q-spinner>
</q-form> </q-form>
@ -395,7 +395,7 @@
</p> </p>
{% endraw %} {% endraw %}
<div v-if="canPay" class="row q-mt-lg"> <div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn> <q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
<div v-else class="row q-mt-lg"> <div v-else class="row q-mt-lg">
@ -423,7 +423,7 @@
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code> <code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p> </p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn> <q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn >Cancel</q-btn
> >
@ -485,9 +485,7 @@
</div> </div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit" <q-btn unelevated color="primary" type="submit">Send satoshis</q-btn>
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn >Cancel</q-btn
> >
@ -512,7 +510,7 @@
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="parse.data.request == ''" :disable="parse.data.request == ''"
type="submit" type="submit"
>Read</q-btn >Read</q-btn

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
import json import json
import lnurl # type: ignore import lnurl # type: ignore
import httpx import httpx

View file

@ -56,11 +56,11 @@ async def extensions():
if extension_to_enable: if extension_to_enable:
await update_user_extension( await update_user_extension(
user_id=g.user.id, extension=extension_to_enable, active=1 user_id=g.user.id, extension=extension_to_enable, active=True
) )
elif extension_to_disable: elif extension_to_disable:
await update_user_extension( await update_user_extension(
user_id=g.user.id, extension=extension_to_disable, active=0 user_id=g.user.id, extension=extension_to_disable, active=False
) )
return await render_template("core/extensions.html", user=await get_user(g.user.id)) return await render_template("core/extensions.html", user=await get_user(g.user.id))

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
import datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from quart import jsonify from quart import jsonify
@ -32,6 +32,24 @@ async def api_public_payment_longpolling(payment_hash):
print("adding standalone invoice listener", payment_hash, send_payment) print("adding standalone invoice listener", payment_hash, send_payment)
api_invoice_listeners.append(send_payment) api_invoice_listeners.append(send_payment)
async for payment in receive_payment: response = None
if payment.payment_hash == payment_hash:
return jsonify({"status": "paid"}), HTTPStatus.OK async def payment_info_receiver(cancel_scope):
async for payment in receive_payment:
if payment.payment_hash == payment_hash:
nonlocal response
response = (jsonify({"status": "paid"}), HTTPStatus.OK)
cancel_scope.cancel()
async def timeouter(cancel_scope):
await trio.sleep(45)
cancel_scope.cancel()
async with trio.open_nursery() as nursery:
nursery.start_soon(payment_info_receiver, nursery.cancel_scope)
nursery.start_soon(timeouter, nursery.cancel_scope)
if response:
return response
else:
return jsonify({"message": "timeout"}), HTTPStatus.REQUEST_TIMEOUT

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,36 +1,125 @@
import os import os
import trio import trio
import time
from typing import Optional
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from sqlalchemy import create_engine # type: ignore from sqlalchemy import create_engine # type: ignore
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
from sqlalchemy_aio.base import AsyncConnection from sqlalchemy_aio.base import AsyncConnection # type: ignore
from .settings import LNBITS_DATA_FOLDER from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
class Connection: class Compat:
def __init__(self, conn: AsyncConnection): type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"interval '{seconds} seconds'"
elif self.type == SQLITE:
return f"{seconds}"
return "<nothing>"
@property
def timestamp_now(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "now()"
elif self.type == SQLITE:
return "(strftime('%s', 'now'))"
return "<nothing>"
@property
def serial_primary_key(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "SERIAL PRIMARY KEY"
elif self.type == SQLITE:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
return "<nothing>"
@property
def references_schema(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"{self.schema}."
elif self.type == SQLITE:
return ""
return "<nothing>"
class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
self.conn = conn self.conn = conn
self.txn = txn
self.type = typ
self.name = name
self.schema = schema
def rewrite_query(self, query) -> str:
if self.type in {POSTGRES, COCKROACH}:
query = query.replace("%", "%%")
query = query.replace("?", "%s")
return query
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(query, values) result = await self.conn.execute(self.rewrite_query(query), values)
return await result.fetchall() return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(query, values) result = await self.conn.execute(self.rewrite_query(query), values)
row = await result.fetchone() row = await result.fetchone()
await result.close() await result.close()
return row return row
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(query, values) return await self.conn.execute(self.rewrite_query(query), values)
class Database: class Database(Compat):
def __init__(self, db_name: str): def __init__(self, db_name: str):
self.db_name = db_name self.name = db_name
db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
self.engine = create_engine(f"sqlite:///{db_path}", strategy=TRIO_STRATEGY) if LNBITS_DATABASE_URL:
database_uri = LNBITS_DATABASE_URL
if database_uri.startswith("cockroachdb://"):
self.type = COCKROACH
else:
self.type = POSTGRES
import psycopg2 # type: ignore
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
psycopg2.extensions.DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
psycopg2.extensions.TIME.values + psycopg2.extensions.DATE.values,
"DATE2INT",
lambda value, curs: time.mktime(value.timetuple())
if value is not None
else None,
)
)
else:
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
database_uri = f"sqlite:///{self.path}"
self.type = SQLITE
self.schema = self.name
if self.name.startswith("ext_"):
self.schema = self.name[4:]
else:
self.schema = None
self.engine = create_engine(database_uri, strategy=TRIO_STRATEGY)
self.lock = trio.StrictFIFOLock() self.lock = trio.StrictFIFOLock()
@asynccontextmanager @asynccontextmanager
@ -38,8 +127,20 @@ class Database:
await self.lock.acquire() await self.lock.acquire()
try: try:
async with self.engine.connect() as conn: async with self.engine.connect() as conn:
async with conn.begin(): async with conn.begin() as txn:
yield Connection(conn) wconn = Connection(conn, txn, self.type, self.name, self.schema)
if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn
finally: finally:
self.lock.release() self.lock.release()

View file

@ -10,7 +10,7 @@ async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
await db.execute( await db.execute(
""" """
INSERT INTO amilks (id, wallet, lnurl, atime, amount) INSERT INTO amilk.amilks (id, wallet, lnurl, atime, amount)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
(amilk_id, wallet_id, lnurl, atime, amount), (amilk_id, wallet_id, lnurl, atime, amount),
@ -22,7 +22,7 @@ async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -
async def get_amilk(amilk_id: str) -> Optional[AMilk]: async def get_amilk(amilk_id: str) -> Optional[AMilk]:
row = await db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,)) row = await db.fetchone("SELECT * FROM amilk.amilks WHERE id = ?", (amilk_id,))
return AMilk(**row) if row else None return AMilk(**row) if row else None
@ -32,11 +32,11 @@ async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM amilk.amilks WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [AMilk(**row) for row in rows] return [AMilk(**row) for row in rows]
async def delete_amilk(amilk_id: str) -> None: async def delete_amilk(amilk_id: str) -> None:
await db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,)) await db.execute("DELETE FROM amilk.amilks WHERE id = ?", (amilk_id,))

View file

@ -4,7 +4,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS amilks ( CREATE TABLE amilk.amilks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
lnurl TEXT NOT NULL, lnurl TEXT NOT NULL,

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true" <q-btn unelevated color="primary" @click="amilkDialog.show = true"
>New AMilk</q-btn >New AMilk</q-btn
> >
</q-card-section> </q-card-section>
@ -109,7 +109,7 @@
></q-input> ></q-input>
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null" :disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
type="submit" type="submit"
>Create amilk</q-btn >Create amilk</q-btn

View file

@ -21,7 +21,7 @@ async def create_bleskomat(
api_key_encoding = "hex" api_key_encoding = "hex"
await db.execute( await db.execute(
""" """
INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee) INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -42,13 +42,15 @@ async def create_bleskomat(
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]: async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,)) row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
return Bleskomat(**row) if row else None return Bleskomat(**row) if row else None
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]: async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,) "SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
) )
return Bleskomat(**row) if row else None return Bleskomat(**row) if row else None
@ -58,7 +60,7 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Bleskomat(**row) for row in rows] return [Bleskomat(**row) for row in rows]
@ -66,14 +68,17 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]: async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id) f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
(*kwargs.values(), bleskomat_id),
)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
) )
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
return Bleskomat(**row) if row else None return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None: async def delete_bleskomat(bleskomat_id: str) -> None:
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,)) await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl( async def create_bleskomat_lnurl(
@ -84,7 +89,7 @@ async def create_bleskomat_lnurl(
now = int(time.time()) now = int(time.time())
await db.execute( await db.execute(
""" """
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time) INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -108,5 +113,7 @@ async def create_bleskomat_lnurl(
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]: async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
hash = generate_bleskomat_lnurl_hash(secret) hash = generate_bleskomat_lnurl_hash(secret)
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,)) row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
)
return BleskomatLnurl(**row) if row else None return BleskomatLnurl(**row) if row else None

View file

@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS bleskomats ( CREATE TABLE bleskomat.bleskomats (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL, api_key_id TEXT NOT NULL,
@ -19,7 +19,7 @@ async def m001_initial(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS bleskomat_lnurls ( CREATE TABLE bleskomat.bleskomat_lnurls (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL, bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,

View file

@ -100,7 +100,7 @@ class BleskomatLnurl(NamedTuple):
now = int(time.time()) now = int(time.time())
result = await conn.execute( result = await conn.execute(
""" """
UPDATE bleskomat_lnurls UPDATE bleskomat.bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ? SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ? WHERE id = ?
AND remaining_uses > 0 AND remaining_uses > 0

View file

@ -11,7 +11,7 @@
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="formDialog.show = true"
>Add Bleskomat</q-btn >Add Bleskomat</q-btn
> >
</q-card-section> </q-card-section>
@ -150,14 +150,14 @@
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update Bleskomat</q-btn >Update Bleskomat</q-btn
> >
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable=" :disable="
formDialog.data.wallet == null || formDialog.data.wallet == null ||
formDialog.data.name == null || formDialog.data.name == null ||

View file

@ -18,7 +18,7 @@ async def create_captcha(
captcha_id = urlsafe_short_hash() captcha_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers) INSERT INTO captcha.captchas (id, wallet, url, memo, description, amount, remembers)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(captcha_id, wallet_id, url, memo, description, amount, int(remembers)), (captcha_id, wallet_id, url, memo, description, amount, int(remembers)),
@ -30,7 +30,9 @@ async def create_captcha(
async def get_captcha(captcha_id: str) -> Optional[Captcha]: async def get_captcha(captcha_id: str) -> Optional[Captcha]:
row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,)) row = await db.fetchone(
"SELECT * FROM captcha.captchas WHERE id = ?", (captcha_id,)
)
return Captcha.from_row(row) if row else None return Captcha.from_row(row) if row else None
@ -41,11 +43,11 @@ async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM captcha.captchas WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Captcha.from_row(row) for row in rows] return [Captcha.from_row(row) for row in rows]
async def delete_captcha(captcha_id: str) -> None: async def delete_captcha(captcha_id: str) -> None:
await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,)) await db.execute("DELETE FROM captcha.captchas WHERE id = ?", (captcha_id,))

View file

@ -1,20 +1,19 @@
from sqlalchemy.exc import OperationalError # type: ignore
async def m001_initial(db): async def m001_initial(db):
""" """
Initial captchas table. Initial captchas table.
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS captchas ( CREATE TABLE captcha.captchas (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
memo TEXT NOT NULL, memo TEXT NOT NULL,
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
@ -24,44 +23,41 @@ async def m002_redux(db):
""" """
Creates an improved captchas table and migrates the existing data. Creates an improved captchas table and migrates the existing data.
""" """
try: await db.execute("ALTER TABLE captcha.captchas RENAME TO captchas_old")
await db.execute("SELECT remembers FROM captchas") await db.execute(
"""
CREATE TABLE captcha.captchas (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
description TEXT NULL,
amount INTEGER DEFAULT 0,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """,
remembers INTEGER DEFAULT 0,
extras TEXT NULL
);
"""
)
except OperationalError: for row in [
await db.execute("ALTER TABLE captchas RENAME TO captchas_old") list(row) for row in await db.fetchall("SELECT * FROM captcha.captchas_old")
]:
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS captchas ( INSERT INTO captcha.captchas (
id TEXT PRIMARY KEY, id,
wallet TEXT NOT NULL, wallet,
url TEXT NOT NULL, url,
memo TEXT NOT NULL, memo,
description TEXT NULL, amount,
amount INTEGER DEFAULT 0, time
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
remembers INTEGER DEFAULT 0,
extras TEXT NULL
);
"""
)
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)")
for row in [
list(row) for row in await db.fetchall("SELECT * FROM captchas_old")
]:
await db.execute(
"""
INSERT INTO captchas (
id,
wallet,
url,
memo,
amount,
time
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(row[0], row[1], row[3], row[4], row[5], row[6]),
) )
VALUES (?, ?, ?, ?, ?, ?)
""",
(row[0], row[1], row[3], row[4], row[5], row[6]),
)
await db.execute("DROP TABLE captchas_old") await db.execute("DROP TABLE captcha.captchas_old")

View file

@ -24,7 +24,7 @@
dense dense
flat flat
icon="check" icon="check"
color="deep-purple" color="primary"
type="submit" type="submit"
@click="createInvoice" @click="createInvoice"
:disabled="userAmount < captchaAmount || paymentReq" :disabled="userAmount < captchaAmount || paymentReq"

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="formDialog.show = true"
>New captcha</q-btn >New captcha</q-btn
> >
</q-card-section> </q-card-section>
@ -141,7 +141,7 @@
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
v-model="formDialog.data.remembers" v-model="formDialog.data.remembers"
color="deep-purple" color="primary"
></q-checkbox> ></q-checkbox>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -157,7 +157,7 @@
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.memo == null" :disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.memo == null"
type="submit" type="submit"
>Create captcha</q-btn >Create captcha</q-btn

View file

@ -0,0 +1,3 @@
# StreamerCopilot
Tool to help streamers accept sats for tips

View file

@ -0,0 +1,17 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_copilot")
copilot_ext: Blueprint = Blueprint(
"copilot", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
copilot_ext.record(record_async(register_listeners))

View file

@ -0,0 +1,8 @@
{
"name": "StreamerCopilot",
"short_description": "Video tips/animations/webhooks",
"icon": "face",
"contributors": [
"arcbtc"
]
}

View file

@ -0,0 +1,109 @@
from typing import List, Optional, Union
# from lnbits.db import open_ext_db
from . import db
from .models import Copilots
from lnbits.helpers import urlsafe_short_hash
from quart import jsonify
###############COPILOTS##########################
async def create_copilot(
title: str,
user: str,
lnurl_toggle: Optional[int] = 0,
wallet: Optional[str] = None,
animation1: Optional[str] = None,
animation2: Optional[str] = None,
animation3: Optional[str] = None,
animation1threshold: Optional[int] = None,
animation2threshold: Optional[int] = None,
animation3threshold: Optional[int] = None,
animation1webhook: Optional[str] = None,
animation2webhook: Optional[str] = None,
animation3webhook: Optional[str] = None,
lnurl_title: Optional[str] = None,
show_message: Optional[int] = 0,
show_ack: Optional[int] = 0,
show_price: Optional[int] = 0,
amount_made: Optional[int] = None,
) -> Copilots:
copilot_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO copilots (
id,
"user",
lnurl_toggle,
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
show_message,
show_ack,
show_price,
lnurl_title,
amount_made
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
copilot_id,
user,
lnurl_toggle,
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
show_message,
show_ack,
show_price,
lnurl_title,
0,
),
)
return await get_copilot(copilot_id)
async def update_copilot(copilot_id: str, **kwargs) -> Optional[Copilots]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE copilots SET {q} WHERE id = ?", (*kwargs.values(), copilot_id)
)
row = await db.fetchone("SELECT * FROM copilots WHERE id = ?", (copilot_id,))
return Copilots.from_row(row) if row else None
async def get_copilot(copilot_id: str) -> Copilots:
row = await db.fetchone("SELECT * FROM copilots WHERE id = ?", (copilot_id,))
return Copilots.from_row(row) if row else None
async def get_copilots(user: str) -> List[Copilots]:
rows = await db.fetchall("""SELECT * FROM copilots WHERE "user" = ?""", (user,))
return [Copilots.from_row(row) for row in rows]
async def delete_copilot(copilot_id: str) -> None:
await db.execute("DELETE FROM copilots WHERE id = ?", (copilot_id,))

View file

@ -0,0 +1,86 @@
import json
import hashlib
import math
from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnurl.types import LnurlPayMetadata
from lnbits.core.services import create_invoice
from . import copilot_ext
from .crud import get_copilot
@copilot_ext.route("/lnurl/<cp_id>", methods=["GET"])
async def lnurl_response(cp_id):
cp = await get_copilot(cp_id)
if not cp:
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
resp = LnurlPayResponse(
callback=url_for("copilot.lnurl_callback", cp_id=cp_id, _external=True),
min_sendable=10000,
max_sendable=50000000,
metadata=LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
)
params = resp.dict()
if cp.show_message:
params["commentAllowed"] = 300
return jsonify(params)
@copilot_ext.route("/lnurl/cb/<cp_id>", methods=["GET"])
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"))
if amount_received < 10000:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum 10 sats."
).dict()
),
)
elif amount_received / 1000 > 10000000:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum 50000."
).dict()
),
)
comment = ""
if request.args.get("comment"):
comment = request.args.get("comment")
if len(comment or "") > 300:
return jsonify(
LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
).dict()
)
if len(comment) < 1:
comment = "none"
payment_hash, payment_request = await create_invoice(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=hashlib.sha256(
(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8")
).digest(),
extra={"tag": "copilot", "copilot": cp.id, "comment": comment},
)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=None,
disposable=False,
routes=[],
)
return jsonify(resp.dict())

View file

@ -0,0 +1,33 @@
async def m001_initial(db):
"""
Initial copilot table.
"""
await db.execute(
f"""
CREATE TABLE copilot.copilots (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
lnurl_toggle INTEGER,
wallet TEXT,
animation1 TEXT,
animation2 TEXT,
animation3 TEXT,
animation1threshold INTEGER,
animation2threshold INTEGER,
animation3threshold INTEGER,
animation1webhook TEXT,
animation2webhook TEXT,
animation3webhook TEXT,
lnurl_title TEXT,
show_message INTEGER,
show_ack INTEGER,
show_price INTEGER,
amount_made INTEGER,
fullscreen_cam INTEGER,
iframe_url TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)

View file

@ -0,0 +1,41 @@
from sqlite3 import Row
from typing import NamedTuple
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):
id: str
user: str
title: str
lnurl_toggle: int
wallet: str
animation1: str
animation2: str
animation3: str
animation1threshold: int
animation2threshold: int
animation3threshold: int
animation1webhook: str
animation2webhook: str
animation3webhook: str
lnurl_title: str
show_message: int
show_ack: int
show_price: int
amount_made: int
timestamp: int
fullscreen_cam: int
iframe_url: str
@classmethod
def from_row(cls, row: Row) -> "Copilots":
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: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

View file

@ -0,0 +1,88 @@
import trio # type: ignore
import json
import httpx
from quart import g, jsonify, url_for, websocket
from http import HTTPStatus
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import get_copilot
from .views import updater
import shortuuid
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
webhook = None
data = None
if "copilot" != payment.extra.get("tag"):
# not an copilot invoice
return
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
copilot = await get_copilot(payment.extra.get("copilot", -1))
if not copilot:
return (
jsonify({"message": "Copilot link link does not exist."}),
HTTPStatus.NOT_FOUND,
)
if copilot.animation1threshold:
if int(payment.amount / 1000) >= copilot.animation1threshold:
data = copilot.animation1
webhook = copilot.animation1webhook
if copilot.animation2threshold:
if int(payment.amount / 1000) >= copilot.animation2threshold:
data = copilot.animation2
webhook = copilot.animation1webhook
if copilot.animation3threshold:
if int(payment.amount / 1000) >= copilot.animation3threshold:
data = copilot.animation3
webhook = copilot.animation1webhook
if webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
webhook,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
},
timeout=40,
)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
if payment.extra.get("comment"):
await updater(copilot.id, data, payment.extra.get("comment"))
else:
await updater(copilot.id, data, "none")
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View file

@ -0,0 +1,172 @@
<q-card>
<q-card-section>
<p>
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Create copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /copilot/api/v1/copilot</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/copilot -d '{"title":
&lt;string&gt;, "animation": &lt;string&gt;,
"show_message":&lt;string&gt;, "amount": &lt;integer&gt;,
"lnurl_title": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}api/v1/copilot/&lt;copilot_id&gt; -d '{"title": &lt;string&gt;,
"animation": &lt;string&gt;, "show_message":&lt;string&gt;,
"amount": &lt;integer&gt;, "lnurl_title": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key:
{{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/copilot/&lt;copilot_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilots">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /copilot/api/v1/copilots</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/copilots -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Trigger an animation"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/api/v1/copilot/ws/&lt;copilot_id&gt;/&lt;comment&gt;/&lt;data&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/api/v1/copilot/ws/&lt;string,
copilot_id&gt;/&lt;string, comment&gt;/&lt;string, gif name&gt; -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View file

@ -0,0 +1,289 @@
{% extends "public.html" %} {% block page %}<q-page>
<video
autoplay="true"
id="videoScreen"
style="width: 100%"
class="fixed-bottom-right"
></video>
<video
autoplay="true"
id="videoCamera"
style="width: 100%"
class="fixed-bottom-right"
></video>
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
<div
v-if="copilot.lnurl_toggle == 1"
class="rounded-borders column fixed-right"
style="
width: 250px;
background-color: white;
height: 300px;
margin-top: 10%;
"
>
<div class="col">
<qrcode
:value="copilot.lnurl"
:options="{width:250}"
class="rounded-borders"
></qrcode>
<center class="absolute-bottom" style="color: black; font-size: 20px">
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
</center>
</div>
</div>
<h2
v-if="copilot.show_price != 0"
class="text-bold fixed-bottom-left"
style="
margin: 60px 60px;
font-size: 110px;
text-shadow: 4px 8px 4px black;
color: white;
"
>
{% raw %}{{ price }}{% endraw %}
</h2>
<p
v-if="copilot.show_ack != 0"
class="fixed-top"
style="
font-size: 22px;
text-shadow: 2px 4px 1px black;
color: white;
padding-left: 40%;
"
>
Powered by LNbits/StreamerCopilot
</p>
</q-page>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style>
body.body--dark .q-drawer,
body.body--dark .q-footer,
body.body--dark .q-header,
.q-drawer,
.q-footer,
.q-header {
display: none;
}
.q-page {
padding: 0px;
}
</style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
price: '',
counter: 1,
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
copilot: {},
animQueue: [],
queue: false,
lnurl: ''
}
},
methods: {
showNotif: function (userMessage) {
var colour = this.colours[
Math.floor(Math.random() * this.colours.length)
]
this.$q.notify({
color: colour,
icon: 'chat_bubble_outline',
html: true,
message: '<h4 style="color: white;">' + userMessage + '</h4>',
position: 'top-left',
timeout: 5000
})
},
openURL: function (url) {
return Quasar.utils.openURL(url)
},
initCamera() {
var video = document.querySelector('#videoCamera')
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
}
},
initScreenShare() {
var video = document.querySelector('#videoScreen')
navigator.mediaDevices
.getDisplayMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
},
pushAnim(content) {
document.getElementById('animations').style.width = content[0]
document.getElementById('animations').src = content[1]
if (content[2] != 'none') {
self.showNotif(content[2])
}
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
},
launch() {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' +
self.copilot.id +
'/launching/rocket'
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
mounted() {
this.initCamera()
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + self.copilot.id,
localStorage.getItem('inkey')
)
.then(function (response) {
self.copilot = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
const obj = JSON.stringify({
event: 'bts:subscribe',
data: {channel: 'live_trades_' + self.copilot.show_price}
})
this.connectionBitStamp.onmessage = function (e) {
if (self.copilot.show_price) {
if (self.copilot.show_price == 'btcusd') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btceur') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btcgbp') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'GBP'
}).format(JSON.parse(e.data).data.price)
)
}
}
}
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
const fetch = data =>
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
const addTask = (() => {
let pending = Promise.resolve()
const run = async data => {
try {
await pending
} finally {
return fetch(data)
}
}
return data => (pending = run(data))
})()
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id +
'/'
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id +
'/'
}
this.connection = new WebSocket(localUrl)
this.connection.onmessage = function (e) {
res = e.data.split('-')
if (res[0] == 'rocket') {
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
}
if (res[0] == 'face') {
addTask(['35%', '/copilot/static/face.gif', res[1]])
}
if (res[0] == 'bitcoin') {
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
}
if (res[0] == 'confetti') {
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
}
if (res[0] == 'martijn') {
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
}
if (res[0] == 'rick') {
addTask(['40%', '/copilot/static/rick.gif', res[1]])
}
if (res[0] == 'true') {
document.getElementById('videoCamera').style.width = '20%'
self.initScreenShare()
}
if (res[0] == 'false') {
document.getElementById('videoCamera').style.width = '100%'
document.getElementById('videoScreen').src = null
}
}
this.connection.onopen = () => this.launch
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,637 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
>New copilot instance
</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Copilots</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
<q-btn flat color="grey" @click="exportcopilotCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
flat
dense
:data="CopilotLinks"
row-key="id"
:columns="CopilotsTable.columns"
:pagination.sync="CopilotsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="apps"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotPanel(props.row.id)"
>
<q-tooltip> Panel </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="face"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotCompose(props.row.id)"
>
<q-tooltip> Compose window </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteCopilotLink(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete copilot </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateCopilotLink(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip> Edit copilot </q-tooltip>
</q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits StreamCopilot Extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCopilot.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.title"
type="text"
label="Title"
></q-input>
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.lnurl_toggle"
label="Include lnurl payment QR? (requires https)"
left-label
></q-checkbox>
</div>
<div v-if="formDialogCopilot.data.lnurl_toggle">
<q-checkbox
v-model="formDialogCopilot.data.show_message"
left-label
label="Show lnurl-pay messages? (supported by few wallets)"
></q-checkbox>
<q-select
filled
dense
emit-value
v-model="formDialogCopilot.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 1"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation1"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1threshold"
type="number"
label="From *sats"
:min="10"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 2 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation1threshold > 0"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation2"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation2threshold"
type="number"
label="From *sats"
:min="formDialogCopilot.data.animation1threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation2webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 3 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation3"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation3threshold"
type="number"
label="From *sats"
:min="formDialogCopilot.data.animation2threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation3webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.lnurl_title"
type="text"
max="1440"
label="Lnurl title (message with QR code)"
>
</q-input>
</div>
<div class="q-gutter-sm">
<q-select
filled
dense
style="width: 50%"
v-model.trim="formDialogCopilot.data.show_price"
:options="currencyOptions"
label="Show price"
/>
</div>
<div class="q-gutter-sm">
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.show_ack"
left-label
label="Show 'powered by LNbits'"
></q-checkbox>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogCopilot.data.id"
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Update Copilot</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Create Copilot</q-btn
>
<q-btn @click="cancelCopilot" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style></style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
var mapCopilot = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
CopilotLinks: [],
CopilotLinksObj: [],
CopilotsTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'id',
field: 'id'
},
{
name: 'lnurl_toggle',
align: 'left',
label: 'Show lnurl pay link',
field: 'lnurl_toggle'
},
{
name: 'title',
align: 'left',
label: 'title',
field: 'title'
},
{
name: 'amount_made',
align: 'left',
label: 'amount made',
field: 'amount_made'
}
],
pagination: {
rowsPerPage: 10
}
},
passedCopilot: {},
formDialog: {
show: false,
data: {}
},
formDialogCopilot: {
show: false,
data: {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
qrCodeDialog: {
show: false,
data: null
},
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
}
},
methods: {
cancelCopilot: function (data) {
var self = this
self.formDialogCopilot.show = false
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
sendFormDataCopilot: function () {
var self = this
if (self.formDialogCopilot.data.id) {
this.updateCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
} else {
this.createCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
}
},
createCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
.then(function (response) {
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilots: function () {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.CopilotLinks = response.data.map(mapCopilot)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilot: function (copilot_id) {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + copilot_id,
this.g.user.wallets[0].inkey
)
.then(function (response) {
localStorage.setItem('copilot', JSON.stringify(response.data))
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
openCopilotCompose: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../copilot/cp/', '_blank', params)
},
openCopilotPanel: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
open('../copilot/pn/', '_blank', params)
},
deleteCopilotLink: function (copilotId) {
var self = this
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/copilot/api/v1/copilot/' + copilotId,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === copilotId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
openUpdateCopilotLink: function (copilotId) {
var self = this
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
self.formDialogCopilot.data = _.clone(copilot._data)
self.formDialogCopilot.show = true
},
updateCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request(
'PUT',
'/copilot/api/v1/copilot/' + updatedData.id,
wallet,
updatedData
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === updatedData.id
})
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
exportcopilotCSV: function () {
var self = this
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
}
},
created: function () {
var self = this
var getCopilots = this.getCopilots
getCopilots()
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,157 @@
{% extends "public.html" %} {% block page %}
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
<q-card class="my-card">
<div class="column">
<div class="col">
<center>
<q-btn
flat
round
dense
@click="openCompose"
icon="face"
style="font-size: 60px"
></q-btn>
</center>
</div>
<center>
<div class="col" style="margin: 15px; font-size: 22px">
Title: {% raw %} {{ copilot.title }} {% endraw %}
</div>
</center>
<q-separator></q-separator>
<div class="col">
<div class="row">
<div class="col">
<q-btn
class="q-mt-sm q-ml-sm"
color="primary"
@click="fullscreenToggle"
label="Screen share"
size="sm"
>
</q-btn>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rocket')"
label="rocket"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('confetti')"
label="confetti"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('face')"
label="face"
size="sm"
/>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rick')"
label="rick"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('martijn')"
label="martijn"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('bitcoin')"
label="bitcoin"
size="sm"
/>
</div>
</div>
</div>
</div>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fullscreen_cam: true,
textareaModel: '',
iframe: '',
copilot: {}
}
},
methods: {
iframeChange: function (url) {
this.connection.send(String(url))
},
fullscreenToggle: function () {
self = this
self.animationBTN(String(this.fullscreen_cam))
if (this.fullscreen_cam) {
this.fullscreen_cam = false
} else {
this.fullscreen_cam = true
}
},
openCompose: function () {
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../cp/', 'test', params)
},
animationBTN: function (name) {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,61 @@
from quart import g, abort, render_template, jsonify, websocket
from http import HTTPStatus
import httpx
from collections import defaultdict
from lnbits.decorators import check_user_exists, validate_uuids
from . import copilot_ext
from .crud import get_copilot
from quart import g, abort, render_template, jsonify, websocket
from functools import wraps
import trio
import shortuuid
from . import copilot_ext
@copilot_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("copilot/index.html", user=g.user)
@copilot_ext.route("/cp/")
async def compose():
return await render_template("copilot/compose.html")
@copilot_ext.route("/pn/")
async def panel():
return await render_template("copilot/panel.html")
##################WEBSOCKET ROUTES########################
# socket_relay is a list where the control panel or
# lnurl endpoints can leave a message for the compose window
connected_websockets = defaultdict(set)
@copilot_ext.websocket("/ws/<id>/")
async def wss(id):
copilot = await get_copilot(id)
if not copilot:
return "", HTTPStatus.FORBIDDEN
global connected_websockets
send_channel, receive_channel = trio.open_memory_channel(0)
connected_websockets[id].add(send_channel)
try:
while True:
data = await receive_channel.receive()
await websocket.send(data)
finally:
connected_websockets[id].remove(send_channel)
async def updater(copilot_id, data, comment):
copilot = await get_copilot(copilot_id)
if not copilot:
return
for queue in connected_websockets[copilot_id]:
await queue.send(f"{data + '-' + comment}")

View file

@ -0,0 +1,109 @@
import hashlib
from quart import g, jsonify, url_for, websocket
from http import HTTPStatus
import httpx
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from .views import updater
from . import copilot_ext
from lnbits.extensions.copilot import copilot_ext
from .crud import (
create_copilot,
update_copilot,
get_copilot,
get_copilots,
delete_copilot,
)
#######################COPILOT##########################
@copilot_ext.route("/api/v1/copilot", methods=["POST"])
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"title": {"type": "string", "empty": False, "required": True},
"lnurl_toggle": {"type": "integer", "empty": False},
"wallet": {"type": "string", "empty": False, "required": False},
"animation1": {"type": "string", "empty": True, "required": False},
"animation2": {"type": "string", "empty": True, "required": False},
"animation3": {"type": "string", "empty": True, "required": False},
"animation1threshold": {"type": "integer", "empty": True, "required": False},
"animation2threshold": {"type": "integer", "empty": True, "required": False},
"animation3threshold": {"type": "integer", "empty": True, "required": False},
"animation1webhook": {"type": "string", "empty": True, "required": False},
"animation2webhook": {"type": "string", "empty": True, "required": False},
"animation3webhook": {"type": "string", "empty": True, "required": False},
"lnurl_title": {"type": "string", "empty": True, "required": False},
"show_message": {"type": "integer", "empty": True, "required": False},
"show_ack": {"type": "integer", "empty": True},
"show_price": {"type": "string", "empty": True},
}
)
async def api_copilot_create_or_update(copilot_id=None):
if not copilot_id:
copilot = await create_copilot(user=g.wallet.user, **g.data)
return jsonify(copilot._asdict()), HTTPStatus.CREATED
else:
copilot = await update_copilot(copilot_id=copilot_id, **g.data)
return jsonify(copilot._asdict()), HTTPStatus.OK
@copilot_ext.route("/api/v1/copilot", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_copilots_retrieve():
try:
return (
jsonify(
[{**copilot._asdict()} for copilot in await get_copilots(g.wallet.user)]
),
HTTPStatus.OK,
)
except:
return ""
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_copilot_retrieve(copilot_id):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND
if not copilot.lnurl_toggle:
return (
jsonify({**copilot._asdict()}),
HTTPStatus.OK,
)
return (
jsonify({**copilot._asdict(), **{"lnurl": copilot.lnurl}}),
HTTPStatus.OK,
)
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_copilot_delete(copilot_id):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
await delete_copilot(copilot_id)
return "", HTTPStatus.NO_CONTENT
@copilot_ext.route("/api/v1/copilot/ws/<copilot_id>/<comment>/<data>", methods=["GET"])
async def api_copilot_ws_relay(copilot_id, comment, data):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND
try:
await updater(copilot_id, data, comment)
except:
return "", HTTPStatus.FORBIDDEN
return "", HTTPStatus.OK

View file

@ -34,7 +34,7 @@ def create_diagonalleys_product(
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity) INSERT INTO diagonalley.products (id, wallet, product, categories, description, image, price, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -57,16 +57,21 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id) f"UPDATE diagonalley.products SET {q} WHERE id = ?",
(*kwargs.values(), product_id),
)
row = db.fetchone(
"SELECT * FROM diagonalley.products WHERE id = ?", (product_id,)
) )
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return get_diagonalleys_indexer(product_id) return get_diagonalleys_indexer(product_id)
def get_diagonalleys_product(product_id: str) -> Optional[Products]: def get_diagonalleys_product(product_id: str) -> Optional[Products]:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,)) row = db.fetchone(
"SELECT * FROM diagonalley.products WHERE id = ?", (product_id,)
)
return Products(**row) if row else None return Products(**row) if row else None
@ -78,7 +83,7 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall( rows = db.fetchall(
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM diagonalley.products WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Products(**row) for row in rows] return [Products(**row) for row in rows]
@ -86,7 +91,7 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
def delete_diagonalleys_product(product_id: str) -> None: def delete_diagonalleys_product(product_id: str) -> None:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM products WHERE id = ?", (product_id,)) db.execute("DELETE FROM diagonalley.products WHERE id = ?", (product_id,))
###Indexers ###Indexers
@ -106,7 +111,7 @@ def create_diagonalleys_indexer(
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email) INSERT INTO diagonalley.indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -131,16 +136,21 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id) f"UPDATE diagonalley.indexers SET {q} WHERE id = ?",
(*kwargs.values(), indexer_id),
)
row = db.fetchone(
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
) )
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return get_diagonalleys_indexer(indexer_id) return get_diagonalleys_indexer(indexer_id)
def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]: def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) roww = db.fetchone(
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
)
try: try:
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"]) x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
if x.status_code == 200: if x.status_code == 200:
@ -148,7 +158,7 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
print("poo") print("poo")
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE indexers SET online = ? WHERE id = ?", "UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
( (
True, True,
indexer_id, indexer_id,
@ -157,7 +167,7 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
else: else:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE indexers SET online = ? WHERE id = ?", "UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
( (
False, False,
indexer_id, indexer_id,
@ -166,7 +176,9 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
except: except:
print("An exception occurred") print("An exception occurred")
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) row = db.fetchone(
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
)
return Indexers(**row) if row else None return Indexers(**row) if row else None
@ -177,7 +189,7 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall( rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,)
) )
for r in rows: for r in rows:
@ -186,7 +198,7 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
if x.status_code == 200: if x.status_code == 200:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE indexers SET online = ? WHERE id = ?", "UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
( (
True, True,
r["id"], r["id"],
@ -195,7 +207,7 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
else: else:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE indexers SET online = ? WHERE id = ?", "UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
( (
False, False,
r["id"], r["id"],
@ -206,14 +218,14 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall( rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Indexers(**row) for row in rows] return [Indexers(**row) for row in rows]
def delete_diagonalleys_indexer(indexer_id: str) -> None: def delete_diagonalleys_indexer(indexer_id: str) -> None:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,)) db.execute("DELETE FROM diagonalley.indexers WHERE id = ?", (indexer_id,))
###Orders ###Orders
@ -236,7 +248,7 @@ def create_diagonalleys_order(
order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -259,7 +271,7 @@ def create_diagonalleys_order(
def get_diagonalleys_order(order_id: str) -> Optional[Orders]: def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,)) row = db.fetchone("SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,))
return Orders(**row) if row else None return Orders(**row) if row else None
@ -271,25 +283,26 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall( rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})", (*wallet_ids,)
) )
for r in rows: for r in rows:
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
if PAID: if PAID:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE orders SET paid = ? WHERE id = ?", "UPDATE diagonalley.orders SET paid = ? WHERE id = ?",
( (
True, True,
r["id"], r["id"],
), ),
) )
rows = db.fetchall( rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})",
(*wallet_ids,),
) )
return [Orders(**row) for row in rows] return [Orders(**row) for row in rows]
def delete_diagonalleys_order(order_id: str) -> None: def delete_diagonalleys_order(order_id: str) -> None:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM orders WHERE id = ?", (order_id,)) db.execute("DELETE FROM diagonalley.orders WHERE id = ?", (order_id,))

View file

@ -4,7 +4,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE diagonalley.products (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
product TEXT NOT NULL, product TEXT NOT NULL,
@ -22,7 +22,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS indexers ( CREATE TABLE diagonalley.indexers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
shopname TEXT NOT NULL, shopname TEXT NOT NULL,
@ -43,7 +43,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE diagonalley.orders (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
productid TEXT NOT NULL, productid TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,

View file

@ -4,10 +4,10 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="productDialog.show = true" <q-btn unelevated color="primary" @click="productDialog.show = true"
>New Product</q-btn >New Product</q-btn
> >
<q-btn unelevated color="deep-purple" @click="indexerDialog.show = true" <q-btn unelevated color="primary" @click="indexerDialog.show = true"
>New Indexer >New Indexer
<q-tooltip> <q-tooltip>
Frontend shop your stall will list its products in Frontend shop your stall will list its products in
@ -282,7 +282,7 @@
<q-btn <q-btn
v-if="productDialog.data.id" v-if="productDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update Product</q-btn >Update Product</q-btn
> >
@ -290,7 +290,7 @@
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable="productDialog.data.image == null :disable="productDialog.data.image == null
|| productDialog.data.product == null || productDialog.data.product == null
|| productDialog.data.description == null || productDialog.data.description == null
@ -374,7 +374,7 @@
<q-btn <q-btn
v-if="indexerDialog.data.id" v-if="indexerDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update Indexer</q-btn >Update Indexer</q-btn
> >
@ -382,7 +382,7 @@
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable="indexerDialog.data.shopname == null :disable="indexerDialog.data.shopname == null
|| indexerDialog.data.shippingzone1 == null || indexerDialog.data.shippingzone1 == null
|| indexerDialog.data.indexeraddress == null || indexerDialog.data.indexeraddress == null

View file

@ -230,7 +230,7 @@ async def api_diagonalley_order_delete(order_id):
async def api_diagonalleys_order_paid(order_id): async def api_diagonalleys_order_paid(order_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE orders SET paid = ? WHERE id = ?", "UPDATE diagonalley.orders SET paid = ? WHERE id = ?",
( (
True, True,
order_id, order_id,
@ -244,13 +244,15 @@ async def api_diagonalleys_order_paid(order_id):
async def api_diagonalleys_order_shipped(order_id): async def api_diagonalleys_order_shipped(order_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
"UPDATE orders SET shipped = ? WHERE id = ?", "UPDATE diagonalley.orders SET shipped = ? WHERE id = ?",
( (
True, True,
order_id, order_id,
), ),
) )
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,)) order = db.fetchone(
"SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,)
)
return ( return (
jsonify( jsonify(
@ -268,12 +270,16 @@ async def api_diagonalleys_order_shipped(order_id):
) )
async def api_diagonalleys_stall_products(indexer_id): async def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) rows = db.fetchone(
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
)
print(rows[1]) print(rows[1])
if not rows: if not rows:
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],)) products = db.fetchone(
"SELECT * FROM diagonalley.products WHERE wallet = ?", (rows[1],)
)
if not products: if not products:
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
@ -293,7 +299,9 @@ async def api_diagonalleys_stall_products(indexer_id):
) )
async def api_diagonalleys_stall_checkshipped(checking_id): async def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,)) rows = db.fetchone(
"SELECT * FROM diagonalley.orders WHERE invoiceid = ?", (checking_id,)
)
return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
@ -329,7 +337,7 @@ async def api_diagonalley_stall_order(indexer_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
""" """
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (

View file

@ -14,7 +14,7 @@ async def create_ticket(
) -> Tickets: ) -> Tickets:
await db.execute( await db.execute(
""" """
INSERT INTO ticket (id, wallet, event, name, email, registered, paid) INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(payment_hash, wallet, event, name, email, False, False), (payment_hash, wallet, event, name, email, False, False),
@ -26,11 +26,11 @@ async def create_ticket(
async def set_ticket_paid(payment_hash: str) -> Tickets: async def set_ticket_paid(payment_hash: str) -> Tickets:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,)) row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
if row[6] != True: if row[6] != True:
await db.execute( await db.execute(
""" """
UPDATE ticket UPDATE events.ticket
SET paid = true SET paid = true
WHERE id = ? WHERE id = ?
""", """,
@ -44,7 +44,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
amount_tickets = eventdata.amount_tickets - 1 amount_tickets = eventdata.amount_tickets - 1
await db.execute( await db.execute(
""" """
UPDATE events UPDATE events.events
SET sold = ?, amount_tickets = ? SET sold = ?, amount_tickets = ?
WHERE id = ? WHERE id = ?
""", """,
@ -57,7 +57,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
async def get_ticket(payment_hash: str) -> Optional[Tickets]: async def get_ticket(payment_hash: str) -> Optional[Tickets]:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,)) row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
return Tickets(**row) if row else None return Tickets(**row) if row else None
@ -67,13 +67,13 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Tickets(**row) for row in rows] return [Tickets(**row) for row in rows]
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,)) await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,))
# EVENTS # EVENTS
@ -93,7 +93,7 @@ async def create_event(
event_id = urlsafe_short_hash() event_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold) INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -118,7 +118,7 @@ async def create_event(
async def update_event(event_id: str, **kwargs) -> Events: async def update_event(event_id: str, **kwargs) -> Events:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id) f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
) )
event = await get_event(event_id) event = await get_event(event_id)
assert event, "Newly updated event couldn't be retrieved" assert event, "Newly updated event couldn't be retrieved"
@ -126,7 +126,7 @@ async def update_event(event_id: str, **kwargs) -> Events:
async def get_event(event_id: str) -> Optional[Events]: async def get_event(event_id: str) -> Optional[Events]:
row = await db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,)) row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,))
return Events(**row) if row else None return Events(**row) if row else None
@ -136,14 +136,14 @@ async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Events(**row) for row in rows] return [Events(**row) for row in rows]
async def delete_event(event_id: str) -> None: async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events WHERE id = ?", (event_id,)) await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,))
# EVENTTICKETS # EVENTTICKETS
@ -151,13 +151,18 @@ async def delete_event(event_id: str) -> None:
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]: async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id) "SELECT * FROM events.ticket WHERE wallet = ? AND event = ?",
(wallet_id, event_id),
) )
return [Tickets(**row) for row in rows] return [Tickets(**row) for row in rows]
async def reg_ticket(ticket_id: str) -> List[Tickets]: async def reg_ticket(ticket_id: str) -> List[Tickets]:
await db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id)) await db.execute(
ticket = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,)) "UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id)
rows = await db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],)) )
ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,))
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = ?", (ticket[1],)
)
return [Tickets(**row) for row in rows] return [Tickets(**row) for row in rows]

View file

@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE events.events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -13,21 +13,25 @@ async def m001_initial(db):
amount_tickets INTEGER NOT NULL, amount_tickets INTEGER NOT NULL,
price_per_ticket INTEGER NOT NULL, price_per_ticket INTEGER NOT NULL,
sold INTEGER NOT NULL, sold INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE events.tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
event TEXT NOT NULL, event TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
registered BOOLEAN NOT NULL, registered BOOLEAN NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
@ -37,7 +41,7 @@ async def m002_changed(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS ticket ( CREATE TABLE events.ticket (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
event TEXT NOT NULL, event TEXT NOT NULL,
@ -45,12 +49,14 @@ async def m002_changed(db):
email TEXT NOT NULL, email TEXT NOT NULL,
registered BOOLEAN NOT NULL, registered BOOLEAN NOT NULL,
paid BOOLEAN NOT NULL, paid BOOLEAN NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]: for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]:
usescsv = "" usescsv = ""
for i in range(row[5]): for i in range(row[5]):
@ -61,7 +67,7 @@ async def m002_changed(db):
usescsv = usescsv[1:] usescsv = usescsv[1:]
await db.execute( await db.execute(
""" """
INSERT INTO ticket ( INSERT INTO events.ticket (
id, id,
wallet, wallet,
event, event,
@ -82,4 +88,4 @@ async def m002_changed(db):
True, True,
), ),
) )
await db.execute("DROP TABLE tickets") await db.execute("DROP TABLE events.tickets")

View file

@ -26,7 +26,7 @@
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq" :disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq"
type="submit" type="submit"
>Submit</q-btn >Submit</q-btn
@ -46,7 +46,7 @@
size="xl" size="xl"
:href="ticketLink.data.link" :href="ticketLink.data.link"
target="_blank" target="_blank"
color="deep-purple" color="primary"
type="a" type="a"
>Link to your ticket!</q-btn >Link to your ticket!</q-btn
> >

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="formDialog.show = true"
>New Event</q-btn >New Event</q-btn
> >
</q-card-section> </q-card-section>
@ -267,14 +267,14 @@
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update Event</q-btn >Update Event</q-btn
> >
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null" :disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn

View file

@ -10,7 +10,7 @@
<br /> <br />
<q-btn unelevated color="deep-purple" @click="showCamera" size="xl" <q-btn unelevated color="primary" @click="showCamera" size="xl"
>Scan ticket</q-btn >Scan ticket</q-btn
> >
</center> </center>
@ -82,7 +82,7 @@
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader) Vue.use(VueQrcodeReader)
var mapEvents = function(obj) { var mapEvents = function (obj) {
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000), new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
@ -94,7 +94,7 @@
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function() { data: function () {
return { return {
tickets: [], tickets: [],
ticketsTable: { ticketsTable: {
@ -119,35 +119,35 @@
} }
}, },
methods: { methods: {
hoverEmail: function(tmp) { hoverEmail: function (tmp) {
this.tickets.data.emailtemp = tmp this.tickets.data.emailtemp = tmp
}, },
closeCamera: function() { closeCamera: function () {
this.sendCamera.show = false this.sendCamera.show = false
}, },
showCamera: function() { showCamera: function () {
this.sendCamera.show = true this.sendCamera.show = true
}, },
decodeQR: function(res) { decodeQR: function (res) {
this.sendCamera.show = false this.sendCamera.show = false
var self = this var self = this
LNbits.api LNbits.api
.request('GET', '/events/api/v1/register/ticket/' + res) .request('GET', '/events/api/v1/register/ticket/' + res)
.then(function(response) { .then(function (response) {
self.$q.notify({ self.$q.notify({
type: 'positive', type: 'positive',
message: 'Registered!' message: 'Registered!'
}) })
setTimeout(function() { setTimeout(function () {
window.location.reload() window.location.reload()
}, 2000) }, 2000)
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
getEventTickets: function() { getEventTickets: function () {
var self = this var self = this
console.log('obj') console.log('obj')
LNbits.api LNbits.api
@ -155,17 +155,17 @@
'GET', 'GET',
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}' '/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
) )
.then(function(response) { .then(function (response) {
self.tickets = response.data.map(function(obj) { self.tickets = response.data.map(function (obj) {
return mapEvents(obj) return mapEvents(obj)
}) })
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
} }
}, },
created: function() { created: function () {
this.getEventTickets() this.getEventTickets()
} }
}) })

View file

@ -1,11 +1,10 @@
# async def m001_initial(db): # async def m001_initial(db):
# await db.execute( # await db.execute(
# """ # f"""
# CREATE TABLE IF NOT EXISTS example ( # CREATE TABLE example.example (
# id TEXT PRIMARY KEY, # id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL, # wallet TEXT NOT NULL,
# time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) # time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
# ); # );
# """ # """
# ) # )

View file

@ -0,0 +1,3 @@
<h1>Hivemind</h1>
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.

View file

@ -0,0 +1,11 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_hivemind")
hivemind_ext: Blueprint = Blueprint(
"hivemind", __name__, static_folder="static", template_folder="templates"
)
from .views import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Hivemind",
"short_description": "Make cheap talk expensive!",
"icon": "batch_prediction",
"contributors": ["fiatjaf"]
}

View file

@ -0,0 +1,10 @@
# async def m001_initial(db):
# await db.execute(
# f"""
# CREATE TABLE hivemind.hivemind (
# id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL,
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
# );
# """
# )

View file

@ -0,0 +1,11 @@
# from sqlite3 import Row
# from typing import NamedTuple
# class Example(NamedTuple):
# id: str
# wallet: str
#
# @classmethod
# def from_row(cls, row: Row) -> "Example":
# return cls(**dict(row))

View file

@ -0,0 +1,35 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">
This extension is just a placeholder for now.
</h5>
<p>
<a href="https://bitcoinhivemind.com/">Hivemind</a> is a Bitcoin sidechain
project for a peer-to-peer oracle protocol that absorbs accurate data into
a blockchain so that Bitcoin users can speculate in prediction markets.
</p>
<p>
These markets have the potential to revolutionize the emergence of
diffusion of knowledge in society and fix all sorts of problems in the
world.
</p>
<p>
This extension will become fully operative when the
<a href="https://drivechain.xyz/">BIP300</a> soft-fork gets activated and
Bitcoin Hivemind is launched.
</p>
</q-card-section>
</q-card>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,12 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import hivemind_ext
@hivemind_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("hivemind/index.html", user=g.user)

View file

@ -1,5 +1,36 @@
# Jukebox # Jukebox
To use this extension you need a Spotify client ID and client secret. You get these by creating an app in the Spotify developers dashboard here https://developer.spotify.com/dashboard/applications ## An actual Jukebox where users pay sats to play their favourite music from your playlists
Select the playlists you want people to be able to pay for, share the frontend page, profit :) **Note:** To use this extension you need a Premium Spotify subscription.
## Usage
1. Click on "ADD SPOTIFY JUKEBOX"\
![add jukebox](https://i.imgur.com/NdVoKXd.png)
2. Follow the steps required on the form\
- give your jukebox a name
- select a wallet to receive payment
- define the price a user must pay to select a song\
![pick wallet price](https://i.imgur.com/4bJ8mb9.png)
- follow the steps to get your Spotify App and get the client ID and secret key\
![spotify keys](https://i.imgur.com/w2EzFtB.png)
- paste the codes in the form\
![api keys](https://i.imgur.com/6b9xauo.png)
- copy the _Redirect URL_ presented on the form\
![redirect url](https://i.imgur.com/GMzl0lG.png)
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
![spotify app setting](https://i.imgur.com/vb0x4Tl.png)
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
![select playlists](https://i.imgur.com/g4dbtED.png)
3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\
![shareable jukebox](https://i.imgur.com/EAh9PI0.png)
4. The users will see the Jukebox page and choose a song from the selected playlist\
![select song](https://i.imgur.com/YYjeQAs.png)
5. After selecting a song they'd like to hear next a dialog will show presenting the music\
![play for sats](https://i.imgur.com/eEHl3o8.png)
6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing

View file

@ -10,3 +10,8 @@ jukebox_ext: Blueprint = Blueprint(
from .views_api import * # noqa from .views_api import * # noqa
from .views import * # noqa from .views import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
jukebox_ext.record(record_async(register_listeners))

View file

@ -21,7 +21,7 @@ async def create_jukebox(
juke_id = urlsafe_short_hash() juke_id = urlsafe_short_hash()
result = await db.execute( result = await db.execute(
""" """
INSERT INTO jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -47,35 +47,35 @@ async def create_jukebox(
async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]: async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id) f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
) )
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
async def get_jukebox(juke_id: str) -> Optional[Jukebox]: async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]: async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (user,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
async def get_jukeboxs(user: str) -> List[Jukebox]: async def get_jukeboxs(user: str) -> List[Jukebox]:
rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,)) rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
for row in rows: for row in rows:
if row.sp_playlists == "": if row.sp_playlists == "":
await delete_jukebox(row.id) await delete_jukebox(row.id)
rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,)) rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
return [Jukebox.from_row(row) for row in rows] return [Jukebox.from_row(row) for row in rows]
async def delete_jukebox(juke_id: str): async def delete_jukebox(juke_id: str):
await db.execute( await db.execute(
""" """
DELETE FROM jukebox WHERE id = ? DELETE FROM jukebox.jukebox WHERE id = ?
""", """,
(juke_id), (juke_id),
) )
@ -89,7 +89,7 @@ async def create_jukebox_payment(
) -> JukeboxPayment: ) -> JukeboxPayment:
result = await db.execute( result = await db.execute(
""" """
INSERT INTO jukebox_payment (payment_hash, juke_id, song_id, paid) INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", """,
( (
@ -109,7 +109,7 @@ async def update_jukebox_payment(
) -> Optional[JukeboxPayment]: ) -> Optional[JukeboxPayment]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE jukebox_payment SET {q} WHERE payment_hash = ?", f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?",
(*kwargs.values(), payment_hash), (*kwargs.values(), payment_hash),
) )
return await get_jukebox_payment(payment_hash) return await get_jukebox_payment(payment_hash)
@ -117,6 +117,6 @@ async def update_jukebox_payment(
async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]: async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM jukebox_payment WHERE payment_hash = ?", (payment_hash,) "SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,)
) )
return JukeboxPayment(**row) if row else None return JukeboxPayment(**row) if row else None

View file

@ -4,9 +4,9 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE jukebox ( CREATE TABLE jukebox.jukebox (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user TEXT, "user" TEXT,
title TEXT, title TEXT,
wallet TEXT, wallet TEXT,
inkey TEXT, inkey TEXT,
@ -29,7 +29,7 @@ async def m002_initial(db):
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE jukebox_payment ( CREATE TABLE jukebox.jukebox_payment (
payment_hash TEXT PRIMARY KEY, payment_hash TEXT PRIMARY KEY,
juke_id TEXT, juke_id TEXT,
song_id TEXT, song_id TEXT,

View file

@ -46,12 +46,6 @@ new Vue({
align: 'left', align: 'left',
label: 'Price', label: 'Price',
field: 'price' field: 'price'
},
{
name: 'profit',
align: 'left',
label: 'Profit',
field: 'profit'
} }
], ],
pagination: { pagination: {
@ -93,7 +87,11 @@ new Vue({
getJukeboxes() { getJukeboxes() {
self = this self = this
LNbits.api LNbits.api
.request('GET', '/jukebox/api/v1/jukebox', self.g.user.wallets[0].adminkey) .request(
'GET',
'/jukebox/api/v1/jukebox',
self.g.user.wallets[0].adminkey
)
.then(function (response) { .then(function (response) {
self.JukeboxLinks = response.data.map(mapJukebox) self.JukeboxLinks = response.data.map(mapJukebox)
}) })
@ -165,10 +163,10 @@ new Vue({
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
}, },
authAccess() { authAccess() {
self = this self = this
self.requestAuthorization() self.requestAuthorization()
self.getSpotifyTokens() self.getSpotifyTokens()
self.$q.notify({ self.$q.notify({
spinner: true, spinner: true,
message: 'Processing', message: 'Processing',
@ -195,37 +193,37 @@ new Vue({
if (self.jukeboxDialog.data.sp_access_token) { if (self.jukeboxDialog.data.sp_access_token) {
self.refreshPlaylists() self.refreshPlaylists()
self.refreshDevices() self.refreshDevices()
console.log("this.devices") console.log('this.devices')
console.log(self.devices) console.log(self.devices)
console.log("this.devices") console.log('this.devices')
setTimeout(function () { setTimeout(function () {
if (self.devices.length < 1 || self.playlists.length < 1) { if (self.devices.length < 1 || self.playlists.length < 1) {
self.$q.notify({ self.$q.notify({
spinner: true, spinner: true,
color: 'red', color: 'red',
message: message:
'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something', 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
timeout: 10000 timeout: 10000
})
LNbits.api
.request(
'DELETE',
'/jukebox/api/v1/jukebox/' + response.data.id,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.getJukeboxes()
}) })
.catch(err => { LNbits.api
LNbits.utils.notifyApiError(err) .request(
}) 'DELETE',
clearInterval(timerId) '/jukebox/api/v1/jukebox/' + response.data.id,
self.closeFormDialog() self.g.user.wallets[0].adminkey
} else { )
self.step = 4 .then(function (response) {
clearInterval(timerId) self.getJukeboxes()
} })
}, 2000) .catch(err => {
LNbits.utils.notifyApiError(err)
})
clearInterval(timerId)
self.closeFormDialog()
} else {
self.step = 4
clearInterval(timerId)
}
}, 2000)
} }
} }
}) })
@ -347,15 +345,15 @@ new Vue({
} }
} }
}, },
refreshDevices() { refreshDevices() {
self = this self = this
self.deviceApi( self.deviceApi(
'GET', 'GET',
'https://api.spotify.com/v1/me/player/devices', 'https://api.spotify.com/v1/me/player/devices',
null null
) )
}, },
fetchAccessToken(code) { fetchAccessToken(code) {
self = this self = this
let body = 'grant_type=authorization_code' let body = 'grant_type=authorization_code'
body += '&code=' + code body += '&code=' + code
@ -363,16 +361,16 @@ new Vue({
'&redirect_uri=' + '&redirect_uri=' +
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id) encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
self.callAuthorizationApi(body) self.callAuthorizationApi(body)
}, },
refreshAccessToken() { refreshAccessToken() {
self = this self = this
let body = 'grant_type=refresh_token' let body = 'grant_type=refresh_token'
body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token
body += '&client_id=' + self.jukeboxDialog.data.sp_user body += '&client_id=' + self.jukeboxDialog.data.sp_user
self.callAuthorizationApi(body) self.callAuthorizationApi(body)
}, },
callAuthorizationApi(body) { callAuthorizationApi(body) {
self = this self = this
console.log( console.log(
btoa( btoa(

View file

@ -6,14 +6,9 @@ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data() { data() {
return { return {}
}
}, },
computed: {}, computed: {},
methods: { methods: {},
created() {}
},
created() {
}
}) })

View file

@ -0,0 +1,28 @@
import json
import trio # type: ignore
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from .crud import get_jukebox, update_jukebox_payment
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "jukebox" != payment.extra.get("tag"):
# not a jukebox invoice
return
await update_jukebox_payment(payment.payment_hash, paid=True)

View file

@ -1,24 +1,33 @@
<q-card-section> <q-card-section>
To use this extension you need a Spotify client ID and client secret. You To use this extension you need a Spotify client ID and client secret. You get
get these by creating an app in the Spotify developers dashboard these by creating an app in the Spotify developers dashboard
<a style="color:#43a047" href="https://developer.spotify.com/dashboard/applications">here </a> <a
<br /><br />Select the playlists you want people to be able to pay for, style="color: #43a047"
share the frontend page, profit :) <br /><br /> href="https://developer.spotify.com/dashboard/applications"
Made by, <a style="color:#43a047" href="https://twitter.com/arcbtc">benarc</a>. Inspired by, >here
<a style="color:#43a047" href="https://twitter.com/pirosb3/status/1056263089128161280">pirosb3</a>. </a>
<br /><br />Select the playlists you want people to be able to pay for, share
the frontend page, profit :) <br /><br />
Made by,
<a style="color: #43a047" href="https://twitter.com/arcbtc">benarc</a>.
Inspired by,
<a
style="color: #43a047"
href="https://twitter.com/pirosb3/status/1056263089128161280"
>pirosb3</a
>.
</q-card-section> </q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
<q-expansion-item group="extras" icon="swap_vertical_circle" label="API info" :content-inset-level="0.5"> >
<q-expansion-item group="api" dense expand-separator label="List jukeboxes"> <q-expansion-item group="api" dense expand-separator label="List jukeboxes">
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-blue">GET</span> <code><span class="text-blue">GET</span> /jukebox/api/v1/jukebox</code>
/jukebox/api/v1/jukebox</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
@ -27,7 +36,8 @@
</h5> </h5>
<code>[&lt;jukebox_object&gt;, ...]</code> <code>[&lt;jukebox_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{ <code
>curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}" g.user.wallets[0].adminkey }}"
</code> </code>
</q-card-section> </q-card-section>
@ -36,8 +46,10 @@
<q-expansion-item group="api" dense expand-separator label="Get jukebox"> <q-expansion-item group="api" dense expand-separator label="Get jukebox">
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-blue">GET</span> <code
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code> ><span class="text-blue">GET</span>
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
@ -46,36 +58,44 @@
</h5> </h5>
<code>&lt;jukebox_object&gt;</code> <code>&lt;jukebox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt; -H "X-Api-Key: {{ <code
g.user.wallets[0].adminkey }}" >curl -X GET {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create/update track"> <q-expansion-item
group="api"
dense
expand-separator
label="Create/update track"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-green">POST/PUT</span> <code
/jukebox/api/v1/jukebox/</code> ><span class="text-green">POST/PUT</span>
/jukebox/api/v1/jukebox/</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json) Returns 200 OK (application/json)
</h5> </h5>
<code>&lt;jukbox_object&gt;</code> <code>&lt;jukbox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X POST {{ request.url_root }}api/v1/jukebox/ -d <code
'{"user": &lt;string, user_id&gt;, >curl -X POST {{ request.url_root }}api/v1/jukebox/ -d '{"user":
"title": &lt;string&gt;, "wallet":&lt;string&gt;, "sp_user": &lt;string, user_id&gt;, "title": &lt;string&gt;,
&lt;string, spotify_user_account&gt;, "sp_secret": &lt;string, spotify_user_secret&gt;, "sp_access_token": "wallet":&lt;string&gt;, "sp_user": &lt;string,
&lt;string, not_required&gt;, "sp_refresh_token": spotify_user_account&gt;, "sp_secret": &lt;string,
&lt;string, not_required&gt;, "sp_device": &lt;string, spotify_user_secret&gt;, "sp_playlists": spotify_user_secret&gt;, "sp_access_token": &lt;string,
&lt;string, not_required&gt;, "price": not_required&gt;, "sp_refresh_token": &lt;string, not_required&gt;,
&lt;integer, not_required&gt;}' -H "Content-type: "sp_device": &lt;string, spotify_user_secret&gt;, "sp_playlists":
application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" &lt;string, not_required&gt;, "price": &lt;integer, not_required&gt;}'
-H "Content-type: application/json" -H "X-Api-Key:
{{g.user.wallets[0].adminkey }}"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -83,8 +103,10 @@
<q-expansion-item group="api" dense expand-separator label="Delete jukebox"> <q-expansion-item group="api" dense expand-separator label="Delete jukebox">
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-red">DELETE</span> <code
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code> ><span class="text-red">DELETE</span>
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
@ -93,9 +115,11 @@
</h5> </h5>
<code>&lt;jukebox_object&gt;</code> <code>&lt;jukebox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X DELETE {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt; -H "X-Api-Key: {{ <code
g.user.wallets[0].adminkey }}" >curl -X DELETE {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item></q-expansion-item
>

View file

@ -12,7 +12,9 @@
style="font-size: 20rem" style="font-size: 20rem"
></q-icon> ></q-icon>
<h5 class="q-my-none">Ask the host to turn on the device and launch spotify</h5> <h5 class="q-my-none">
Ask the host to turn on the device and launch spotify
</h5>
<br /> <br />
</center> </center>
</q-card-section> </q-card-section>

View file

@ -4,18 +4,36 @@
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="green-7" class="q-ma-lg" @click="openNewDialog()">Add Spotify Jukebox</q-btn> <q-btn
unelevated
color="primary"
class="q-ma-lg"
@click="openNewDialog()"
>Add Spotify Jukebox</q-btn
>
{% raw %} {% raw %}
<q-table flat dense :data="JukeboxLinks" row-key="id" :columns="JukeboxTable.columns" <q-table
:pagination.sync="JukeboxTable.pagination" :filter="filter"> flat
dense
:data="JukeboxLinks"
row-key="id"
:columns="JukeboxTable.columns"
:pagination.sync="JukeboxTable.pagination"
:filter="filter"
>
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div> <div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div> <div v-else>{{ col.label }}</div>
</q-th> </q-th>
@ -26,18 +44,43 @@
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn unelevated dense size="xs" icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" <q-btn
@click="openQrCodeDialog(props.row.sp_id)"> unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.sp_id)"
>
<q-tooltip> Jukebox QR </q-tooltip> <q-tooltip> Jukebox QR </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
<q-btn flat dense size="xs" @click="updateJukebox(props.row.id)" icon="edit" color="light-blue"></q-btn> <q-btn
<q-btn flat dense size="xs" @click="deleteJukebox(props.row.id)" icon="cancel" color="pink"> flat
dense
size="xs"
@click="updateJukebox(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteJukebox(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete Jukebox </q-tooltip> <q-tooltip> Delete Jukebox </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div> <div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div> <div v-else>{{ col.value }}</div>
</q-td> </q-td>
@ -63,23 +106,62 @@
<q-dialog v-model="jukeboxDialog.show" position="top" @hide="closeFormDialog"> <q-dialog v-model="jukeboxDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-md q-pt-lg q-mt-md" style="width: 100%"> <q-card class="q-pa-md q-pt-lg q-mt-md" style="width: 100%">
<q-stepper v-model="step" active-color="green-7" inactive-color="green-10" vertical animated> <q-stepper
<q-step :name="1" title="Pick wallet, price" icon="account_balance_wallet" :done="step > 1"> v-model="step"
<q-input filled class="q-pt-md" dense v-model.trim="jukeboxDialog.data.title" label="Jukebox name"></q-input> active-color="primary"
<q-select class="q-pb-md q-pt-md" filled dense emit-value v-model="jukeboxDialog.data.wallet" inactive-color="secondary"
:options="g.user.walletOptions" label="Wallet to use"></q-select> vertical
<q-input filled dense v-model.trim="jukeboxDialog.data.price" type="number" max="1440" label="Price per track" animated
class="q-pb-lg"> >
<q-step
:name="1"
title="Pick wallet, price"
icon="account_balance_wallet"
:done="step > 1"
>
<q-input
filled
class="q-pt-md"
dense
v-model.trim="jukeboxDialog.data.title"
label="Jukebox name"
></q-input>
<q-select
class="q-pb-md q-pt-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet to use"
></q-select>
<q-input
filled
dense
v-model.trim="jukeboxDialog.data.price"
type="number"
max="1440"
label="Price per track"
class="q-pb-lg"
>
</q-input> </q-input>
<div class="row"> <div class="row">
<div class="col-4"> <div class="col-4">
<q-btn <q-btn
v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null" v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null"
color="green-7" @click="step = 2">Continue</q-btn> color="primary"
<q-btn v-else color="green-7" disable>Continue</q-btn> @click="step = 2"
>Continue</q-btn
>
<q-btn v-else color="primary" disable>Continue</q-btn>
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn> <q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div> </div>
</div> </div>
@ -90,26 +172,57 @@
<img src="/jukebox/static/spotapi.gif" /> <img src="/jukebox/static/spotapi.gif" />
To use this extension you need a Spotify client ID and client secret. To use this extension you need a Spotify client ID and client secret.
You get these by creating an app in the Spotify developers dashboard You get these by creating an app in the Spotify developers dashboard
<a target="_blank" style="color:#43a047" href="https://developer.spotify.com/dashboard/applications">here</a>. <a
<q-input filled class="q-pb-md q-pt-md" dense v-model.trim="jukeboxDialog.data.sp_user" label="Client ID"> target="_blank"
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here</a
>.
<q-input
filled
class="q-pb-md q-pt-md"
dense
v-model.trim="jukeboxDialog.data.sp_user"
label="Client ID"
>
</q-input> </q-input>
<q-input dense v-model="jukeboxDialog.data.sp_secret" filled :type="isPwd ? 'password' : 'text'" <q-input
label="Client secret"> dense
v-model="jukeboxDialog.data.sp_secret"
filled
:type="isPwd ? 'password' : 'text'"
label="Client secret"
>
<template #append> <template #append>
<q-icon :name="isPwd ? 'visibility_off' : 'visibility'" class="cursor-pointer" @click="isPwd = !isPwd"> <q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
>
</q-icon> </q-icon>
</template> </template>
</q-input> </q-input>
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col-4"> <div class="col-4">
<q-btn v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched" <q-btn
color="green-7" @click="submitSpotifyKeys">Submit keys</q-btn> v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
<q-btn v-else color="green-7" disable color="green-7">Submit keys</q-btn> color="primary"
@click="submitSpotifyKeys"
>Submit keys</q-btn
>
<q-btn v-else color="primary" disable color="primary"
>Submit keys</q-btn
>
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn> <q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div> </div>
</div> </div>
@ -120,42 +233,93 @@
<img src="/jukebox/static/spotapi1.gif" /> <img src="/jukebox/static/spotapi1.gif" />
In the app go to edit-settings, set the redirect URI to this link In the app go to edit-settings, set the redirect URI to this link
<br /> <br />
<q-btn dense outline unelevated color="green-7" size="xs" <q-btn
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')">{% raw %}{{ locationcb dense
}}{{ jukeboxDialog.data.sp_id }}{% endraw outline
unelevated
color="primary"
size="xs"
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
>{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip> %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn> </q-btn>
<br /> <br />
Settings can be found Settings can be found
<a target="_blank" style="color:#43a047" href="https://developer.spotify.com/dashboard/applications">here</a>. <a
target="_blank"
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here</a
>.
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col-4"> <div class="col-4">
<q-btn v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched" <q-btn
color="green-7" @click="authAccess">Authorise access</q-btn> v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
<q-btn v-else color="green-7" disable color="green-7">Authorise access</q-btn> color="primary"
@click="authAccess"
>Authorise access</q-btn
>
<q-btn v-else color="primary" disable color="primary"
>Authorise access</q-btn
>
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn> <q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div> </div>
</div> </div>
<br /> <br />
</q-step> </q-step>
<q-step :name="4" title="Select playlists" icon="queue_music" active-color="green-8" :done="step > 4"> <q-step
<q-select class="q-pb-md q-pt-md" filled dense emit-value v-model="jukeboxDialog.data.sp_device" :name="4"
:options="devices" label="Device jukebox will play to"></q-select> title="Select playlists"
<q-select class="q-pb-md" filled dense multiple emit-value v-model="jukeboxDialog.data.sp_playlists" icon="queue_music"
:options="playlists" label="Playlists available to the jukebox"></q-select> active-color="primary"
:done="step > 4"
>
<q-select
class="q-pb-md q-pt-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.sp_device"
:options="devices"
label="Device jukebox will play to"
></q-select>
<q-select
class="q-pb-md"
filled
dense
multiple
emit-value
v-model="jukeboxDialog.data.sp_playlists"
:options="playlists"
label="Playlists available to the jukebox"
></q-select>
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col-5"> <div class="col-5">
<q-btn v-if="jukeboxDialog.data.sp_device != null && jukeboxDialog.data.sp_playlists != null" <q-btn
color="green-7" @click="createJukebox">Create Jukebox</q-btn> v-if="jukeboxDialog.data.sp_device != null && jukeboxDialog.data.sp_playlists != null"
<q-btn v-else color="green-7" disable>Create Jukebox</q-btn> color="primary"
@click="createJukebox"
>Create Jukebox</q-btn
>
<q-btn v-else color="primary" disable>Create Jukebox</q-btn>
</div> </div>
<div class="col-7"> <div class="col-7">
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn> <q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div> </div>
</div> </div>
</q-step> </q-step>
@ -169,15 +333,28 @@
<h5 class="q-my-none">Shareable Jukebox QR</h5> <h5 class="q-my-none">Shareable Jukebox QR</h5>
</center> </center>
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode :value="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id" :options="{width: 800}" <qrcode
class="rounded-borders"></qrcode> :value="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive> </q-responsive>
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" <q-btn
@click="copyText(qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id, 'Link copied to clipboard!')"> outline
Copy jukebox link</q-btn> color="grey"
<q-btn outline color="grey" type="a" :href="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id" @click="copyText(qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id, 'Link copied to clipboard!')"
target="_blank">Open jukebox</q-btn> >
Copy jukebox link</q-btn
>
<q-btn
outline
color="grey"
type="a"
:href="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
target="_blank"
>Open jukebox</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>

View file

@ -9,7 +9,8 @@
<img style="width: 100px" :src="currentPlay.image" /> <img style="width: 100px" :src="currentPlay.image" />
</div> </div>
<div class="col-8"> <div class="col-8">
<strong style="font-size: 20px">{{ currentPlay.name }}</strong><br /> <strong style="font-size: 20px">{{ currentPlay.name }}</strong
><br />
<strong style="font-size: 15px">{{ currentPlay.artist }}</strong> <strong style="font-size: 15px">{{ currentPlay.artist }}</strong>
</div> </div>
</div> </div>
@ -19,15 +20,30 @@
<q-card class="q-mt-lg"> <q-card class="q-mt-lg">
<q-card-section> <q-card-section>
<p style="font-size: 22px">Pick a song</p> <p style="font-size: 22px">Pick a song</p>
<q-select outlined v-model="playlist" :options="playlists" label="playlists" @input="selectPlaylist()"> <q-select
outlined
v-model="playlist"
:options="playlists"
label="playlists"
@input="selectPlaylist()"
>
</q-select> </q-select>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-virtual-scroll style="max-height: 300px" :items="currentPlaylist" separator> <q-virtual-scroll
style="max-height: 300px"
:items="currentPlaylist"
separator
>
<template v-slot="{ item, index }"> <template v-slot="{ item, index }">
<q-item :key="index" dense clickable v-ripple <q-item
@click="payForSong(item.id, item.name, item.artist, item.image)"> :key="index"
dense
clickable
v-ripple
@click="payForSong(item.id, item.name, item.artist, item.image)"
>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
{{ item.name }} - ({{ item.artist }}) {{ item.name }} - ({{ item.artist }})
@ -55,7 +71,8 @@
</q-card-section> </q-card-section>
<br /> <br />
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="getInvoice(receive.id)">Play for {% endraw %}{{ price }}{% raw %} sats <q-btn outline color="grey" @click="getInvoice(receive.id)"
>Play for {% endraw %}{{ price }}{% raw %} sats
</q-btn> </q-btn>
</div> </div>
</q-card> </q-card>
@ -63,10 +80,16 @@
<q-dialog v-model="receive.dialogues.second" position="top"> <q-dialog v-model="receive.dialogues.second" position="top">
<q-card class="q-pa-lg lnbits__dialog-card"> <q-card class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode :value="'lightning:' + receive.paymentReq" :options="{width: 800}" class="rounded-borders"></qrcode> <qrcode
:value="'lightning:' + receive.paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive> </q-responsive>
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn> <q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -84,7 +107,7 @@
return { return {
currentPlaylist: [], currentPlaylist: [],
currentlyPlaying: {}, currentlyPlaying: {},
cancelListener: () => { }, cancelListener: () => {},
playlists: {}, playlists: {},
playlist: '', playlist: '',
heavyList: [], heavyList: [],
@ -111,14 +134,6 @@
} }
}, },
methods: { methods: {
cancelPayment: function () {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
},
closeReceiveDialog() { },
payForSong(song_id, name, artist, image) { payForSong(song_id, name, artist, image) {
self = this self = this
self.receive.name = name self.receive.name = name
@ -127,66 +142,69 @@
self.receive.id = song_id self.receive.id = song_id
self.receive.dialogues.first = true self.receive.dialogues.first = true
}, },
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.events.onInvoicePaid(
this.selectedWallet,
payment => {
this.paid = true
this.receive.dialogues.first = false
this.receive.dialogues.second = false
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/invoicep/' +
this.receive.id +
'/{{ juke_id }}/' +
this.receive.paymentHash
)
.then(response1 => {
if (response1.data[2] == this.receive.id) {
setTimeout(() => {
this.getCurrent()
}, 500)
this.$q.notify({
color: 'green',
message:
'Success! "' +
this.receive.name +
'" will be played soon',
timeout: 3000
})
this.paid = false
response1 = []
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
self.paid = false
response1 = []
})
}
)
},
getInvoice(song_id) { getInvoice(song_id) {
self = this self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/jukebox/api/v1/jukebox/jb/invoice/' + '/jukebox/api/v1/jukebox/jb/invoice/' +
'{{ juke_id }}' + '{{ juke_id }}' +
'/' + '/' +
song_id song_id
) )
.then(function (response) { .then(function (response) {
self.receive.paymentReq = response.data[0][1] self.receive.paymentReq = response.data[0][1]
self.receive.paymentHash = response.data[0][0] self.receive.paymentHash = response.data[0][0]
self.receive.dialogues.second = true self.receive.dialogues.second = true
var paymentChecker = setInterval(function () { self.$q.notify({
if (!self.paid) { message: 'Processing'
self.checkInvoice(self.receive.paymentHash, '{{ juke_id }}') })
}
if (self.paid) {
clearInterval(paymentChecker)
self.paid = true
self.receive.dialogues.first = false
self.receive.dialogues.second = false
self.$q.notify({
message:
'Processing',
})
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/invoicep/' + song_id + '/{{ juke_id }}/' + self.receive.paymentHash)
.then(function (response1) {
if (response1.data[2] == song_id) {
setTimeout(function () { self.getCurrent() }, 500)
self.$q.notify({
color: 'green',
message:
'Success! "' + self.receive.name + '" will be played soon',
timeout: 3000
})
self.paid = false
response1 = []
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
self.paid = false
response1 = []
})
}
}, 3000)
}) })
.catch(err => { .catch(err => {
self.$q.notify({ self.$q.notify({
color: 'warning', color: 'warning',
html: true, html: true,
@ -196,39 +214,17 @@
}) })
}) })
}, },
checkInvoice(juke_id, paymentHash) {
var self = this
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/checkinvoice/' + juke_id + '/' + paymentHash,
'filla'
)
.then(function (response) {
self.paid = response.data.paid
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCurrent() { getCurrent() {
LNbits.api LNbits.api
.request( .request('GET', '/jukebox/api/v1/jukebox/jb/currently/{{juke_id}}')
'GET',
'/jukebox/api/v1/jukebox/jb/currently/{{juke_id}}')
.then(function (res) { .then(function (res) {
if (res.data.id) { if (res.data.id) {
self.currentlyPlaying = res.data self.currentlyPlaying = res.data
} }
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
selectPlaylist() { selectPlaylist() {
self = this self = this
@ -236,9 +232,9 @@
.request( .request(
'GET', 'GET',
'/jukebox/api/v1/jukebox/jb/playlist/' + '/jukebox/api/v1/jukebox/jb/playlist/' +
'{{ juke_id }}' + '{{ juke_id }}' +
'/' + '/' +
self.playlist.split(',')[0].split('-')[1] self.playlist.split(',')[0].split('-')[1]
) )
.then(function (response) { .then(function (response) {
self.currentPlaylist = response.data self.currentPlaylist = response.data
@ -247,20 +243,21 @@
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
}, },
currentSong() { } currentSong() {}
}, },
created() { created() {
this.getCurrent() this.getCurrent()
this.playlists = JSON.parse('{{ playlists | tojson }}') this.playlists = JSON.parse('{{ playlists | tojson }}')
this.selectedWallet.inkey = '{{ inkey }}'
this.startPaymentNotifier()
self = this self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/jukebox/api/v1/jukebox/jb/playlist/' + '/jukebox/api/v1/jukebox/jb/playlist/' +
'{{ juke_id }}' + '{{ juke_id }}' +
'/' + '/' +
self.playlists[0].split(',')[0].split('-')[1] self.playlists[0].split(',')[0].split('-')[1]
) )
.then(function (response) { .then(function (response) {
self.currentPlaylist = response.data self.currentPlaylist = response.data
@ -268,8 +265,6 @@
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
// this.startPaymentNotifier()
} }
}) })
</script> </script>

View file

@ -9,7 +9,7 @@ from .models import Livestream, Track, Producer
async def create_livestream(*, wallet_id: str) -> int: async def create_livestream(*, wallet_id: str) -> int:
result = await db.execute( result = await db.execute(
""" """
INSERT INTO livestreams (wallet) INSERT INTO livestream.livestreams (wallet)
VALUES (?) VALUES (?)
""", """,
(wallet_id,), (wallet_id,),
@ -18,14 +18,16 @@ async def create_livestream(*, wallet_id: str) -> int:
async def get_livestream(ls_id: int) -> Optional[Livestream]: async def get_livestream(ls_id: int) -> Optional[Livestream]:
row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,)) row = await db.fetchone(
"SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,)
)
return Livestream(**dict(row)) if row else None return Livestream(**dict(row)) if row else None
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]: async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT livestreams.* FROM livestreams SELECT livestreams.* FROM livestream.livestreams
INNER JOIN tracks ON tracks.livestream = livestreams.id INNER JOIN tracks ON tracks.livestream = livestreams.id
WHERE tracks.id = ? WHERE tracks.id = ?
""", """,
@ -35,7 +37,9 @@ async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]: async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,)) row = await db.fetchone(
"SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,)
)
if not row: if not row:
# create on the fly # create on the fly
@ -47,14 +51,14 @@ async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream
async def update_current_track(ls_id: int, track_id: Optional[int]): async def update_current_track(ls_id: int, track_id: Optional[int]):
await db.execute( await db.execute(
"UPDATE livestreams SET current_track = ? WHERE id = ?", "UPDATE livestream.livestreams SET current_track = ? WHERE id = ?",
(track_id, ls_id), (track_id, ls_id),
) )
async def update_livestream_fee(ls_id: int, fee_pct: int): async def update_livestream_fee(ls_id: int, fee_pct: int):
await db.execute( await db.execute(
"UPDATE livestreams SET fee_pct = ? WHERE id = ?", "UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?",
(fee_pct, ls_id), (fee_pct, ls_id),
) )
@ -68,7 +72,7 @@ async def add_track(
) -> int: ) -> int:
result = await db.execute( result = await db.execute(
""" """
INSERT INTO tracks (livestream, name, download_url, price_msat, producer) INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
(livestream, name, download_url, price_msat, producer), (livestream, name, download_url, price_msat, producer),
@ -86,7 +90,7 @@ async def update_track(
) -> int: ) -> int:
result = await db.execute( result = await db.execute(
""" """
UPDATE tracks SET UPDATE livestream.tracks SET
name = ?, name = ?,
download_url = ?, download_url = ?,
price_msat = ?, price_msat = ?,
@ -105,7 +109,7 @@ async def get_track(track_id: Optional[int]) -> Optional[Track]:
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT id, download_url, price_msat, name, producer SELECT id, download_url, price_msat, name, producer
FROM tracks WHERE id = ? FROM livestream.tracks WHERE id = ?
""", """,
(track_id,), (track_id,),
) )
@ -116,7 +120,7 @@ async def get_tracks(livestream: int) -> List[Track]:
rows = await db.fetchall( rows = await db.fetchall(
""" """
SELECT id, download_url, price_msat, name, producer SELECT id, download_url, price_msat, name, producer
FROM tracks WHERE livestream = ? FROM livestream.tracks WHERE livestream = ?
""", """,
(livestream,), (livestream,),
) )
@ -126,7 +130,7 @@ async def get_tracks(livestream: int) -> List[Track]:
async def delete_track_from_livestream(livestream: int, track_id: int): async def delete_track_from_livestream(livestream: int, track_id: int):
await db.execute( await db.execute(
""" """
DELETE FROM tracks WHERE livestream = ? AND id = ? DELETE FROM livestream.tracks WHERE livestream = ? AND id = ?
""", """,
(livestream, track_id), (livestream, track_id),
) )
@ -137,7 +141,7 @@ async def add_producer(livestream: int, name: str) -> int:
existing = await db.fetchall( existing = await db.fetchall(
""" """
SELECT id FROM producers SELECT id FROM livestream.producers
WHERE livestream = ? AND lower(name) = ? WHERE livestream = ? AND lower(name) = ?
""", """,
(livestream, name.lower()), (livestream, name.lower()),
@ -150,7 +154,7 @@ async def add_producer(livestream: int, name: str) -> int:
result = await db.execute( result = await db.execute(
""" """
INSERT INTO producers (livestream, name, user, wallet) INSERT INTO livestream.producers (livestream, name, "user", wallet)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", """,
(livestream, name, user.id, wallet.id), (livestream, name, user.id, wallet.id),
@ -161,8 +165,8 @@ async def add_producer(livestream: int, name: str) -> int:
async def get_producer(producer_id: int) -> Optional[Producer]: async def get_producer(producer_id: int) -> Optional[Producer]:
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT id, user, wallet, name SELECT id, "user", wallet, name
FROM producers WHERE id = ? FROM livestream.producers WHERE id = ?
""", """,
(producer_id,), (producer_id,),
) )
@ -172,8 +176,8 @@ async def get_producer(producer_id: int) -> Optional[Producer]:
async def get_producers(livestream: int) -> List[Producer]: async def get_producers(livestream: int) -> List[Producer]:
rows = await db.fetchall( rows = await db.fetchall(
""" """
SELECT id, user, wallet, name SELECT id, "user", wallet, name
FROM producers WHERE livestream = ? FROM livestream.producers WHERE livestream = ?
""", """,
(livestream,), (livestream,),
) )

View file

@ -3,9 +3,9 @@ async def m001_initial(db):
Initial livestream tables. Initial livestream tables.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE livestreams ( CREATE TABLE livestream.livestreams (
id INTEGER PRIMARY KEY AUTOINCREMENT, id {db.serial_primary_key},
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
fee_pct INTEGER NOT NULL DEFAULT 10, fee_pct INTEGER NOT NULL DEFAULT 10,
current_track INTEGER current_track INTEGER
@ -14,11 +14,11 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE producers ( CREATE TABLE livestream.producers (
livestream INTEGER NOT NULL REFERENCES livestreams (id), livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
id INTEGER PRIMARY KEY AUTOINCREMENT, id {db.serial_primary_key},
user TEXT NOT NULL, "user" TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL name TEXT NOT NULL
); );
@ -26,14 +26,14 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE tracks ( CREATE TABLE livestream.tracks (
livestream INTEGER NOT NULL REFERENCES livestreams (id), livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
id INTEGER PRIMARY KEY AUTOINCREMENT, id {db.serial_primary_key},
download_url TEXT, download_url TEXT,
price_msat INTEGER NOT NULL DEFAULT 0, price_msat INTEGER NOT NULL DEFAULT 0,
name TEXT, name TEXT,
producer INTEGER REFERENCES producers (id) NOT NULL producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL
); );
""" """
) )

View file

@ -1,5 +1,5 @@
import json import json
import trio # type: ignore import trio
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.crud import create_payment from lnbits.core.crud import create_payment

View file

@ -26,7 +26,7 @@
</div> </div>
<div class="col"> <div class="col">
{% raw %} {% raw %}
<q-btn unelevated color="deep-purple" type="submit"> <q-btn unelevated color="primary" type="submit">
{{ nextCurrentTrack && nextCurrentTrack === {{ nextCurrentTrack && nextCurrentTrack ===
livestream.current_track ? 'Stop' : 'Set' }} current track livestream.current_track ? 'Stop' : 'Set' }} current track
</q-btn> </q-btn>
@ -46,7 +46,7 @@
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
<q-btn unelevated color="deep-purple" type="submit" <q-btn unelevated color="primary" type="submit"
>Set percent rate</q-btn >Set percent rate</q-btn
> >
</div> </div>
@ -61,7 +61,7 @@
<h5 class="text-subtitle1 q-my-none">Tracks</h5> <h5 class="text-subtitle1 q-my-none">Tracks</h5>
</div> </div>
<div class="col q-ml-lg"> <div class="col q-ml-lg">
<q-btn unelevated color="deep-purple" @click="openAddTrackDialog" <q-btn unelevated color="primary" @click="openAddTrackDialog"
>Add new track</q-btn >Add new track</q-btn
> >
</div> </div>
@ -296,7 +296,7 @@
<div class="col q-ml-lg"> <div class="col q-ml-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="disabledAddTrackButton()" :disable="disabledAddTrackButton()"
type="submit" type="submit"
> >

View file

@ -1,6 +1,6 @@
{ {
"name": "LndHub", "name": "LndHub",
"short_description": "Access lnbits from BlueWallet or Zeus.", "short_description": "Access lnbits from BlueWallet or Zeus",
"icon": "navigation", "icon": "navigation",
"contributors": ["fiatjaf"] "contributors": ["fiatjaf"]
} }

View file

@ -10,3 +10,8 @@ lnticket_ext: Blueprint = Blueprint(
from .views_api import * # noqa from .views_api import * # noqa
from .views import * # noqa from .views import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
lnticket_ext.record(record_async(register_listeners))

View file

@ -18,7 +18,7 @@ async def create_ticket(
) -> Tickets: ) -> Tickets:
await db.execute( await db.execute(
""" """
INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid) INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(payment_hash, form, email, ltext, name, wallet, sats, False), (payment_hash, form, email, ltext, name, wallet, sats, False),
@ -30,11 +30,13 @@ async def create_ticket(
async def set_ticket_paid(payment_hash: str) -> Tickets: async def set_ticket_paid(payment_hash: str) -> Tickets:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,)) row = await db.fetchone(
"SELECT * FROM lnticket.ticket WHERE id = ?", (payment_hash,)
)
if row[7] == False: if row[7] == False:
await db.execute( await db.execute(
""" """
UPDATE ticket UPDATE lnticket.ticket
SET paid = true SET paid = true
WHERE id = ? WHERE id = ?
""", """,
@ -47,7 +49,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
amount = formdata.amountmade + row[7] amount = formdata.amountmade + row[7]
await db.execute( await db.execute(
""" """
UPDATE form UPDATE lnticket.form
SET amountmade = ? SET amountmade = ?
WHERE id = ? WHERE id = ?
""", """,
@ -77,7 +79,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
async def get_ticket(ticket_id: str) -> Optional[Tickets]: async def get_ticket(ticket_id: str) -> Optional[Tickets]:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,)) row = await db.fetchone("SELECT * FROM lnticket.ticket WHERE id = ?", (ticket_id,))
return Tickets(**row) if row else None return Tickets(**row) if row else None
@ -87,14 +89,14 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM lnticket.ticket WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Tickets(**row) for row in rows] return [Tickets(**row) for row in rows]
async def delete_ticket(ticket_id: str) -> None: async def delete_ticket(ticket_id: str) -> None:
await db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,)) await db.execute("DELETE FROM lnticket.ticket WHERE id = ?", (ticket_id,))
# FORMS # FORMS
@ -111,7 +113,7 @@ async def create_form(
form_id = urlsafe_short_hash() form_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO form (id, wallet, name, webhook, description, costpword, amountmade) INSERT INTO lnticket.form (id, wallet, name, webhook, description, costpword, amountmade)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(form_id, wallet, name, webhook, description, costpword, 0), (form_id, wallet, name, webhook, description, costpword, 0),
@ -124,14 +126,16 @@ async def create_form(
async def update_form(form_id: str, **kwargs) -> Forms: async def update_form(form_id: str, **kwargs) -> Forms:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(f"UPDATE form SET {q} WHERE id = ?", (*kwargs.values(), form_id)) await db.execute(
row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,)) f"UPDATE lnticket.form SET {q} WHERE id = ?", (*kwargs.values(), form_id)
)
row = await db.fetchone("SELECT * FROM lnticket.form WHERE id = ?", (form_id,))
assert row, "Newly updated form couldn't be retrieved" assert row, "Newly updated form couldn't be retrieved"
return Forms(**row) return Forms(**row)
async def get_form(form_id: str) -> Optional[Forms]: async def get_form(form_id: str) -> Optional[Forms]:
row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,)) row = await db.fetchone("SELECT * FROM lnticket.form WHERE id = ?", (form_id,))
return Forms(**row) if row else None return Forms(**row) if row else None
@ -141,11 +145,11 @@ async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,) f"SELECT * FROM lnticket.form WHERE wallet IN ({q})", (*wallet_ids,)
) )
return [Forms(**row) for row in rows] return [Forms(**row) for row in rows]
async def delete_form(form_id: str) -> None: async def delete_form(form_id: str) -> None:
await db.execute("DELETE FROM form WHERE id = ?", (form_id,)) await db.execute("DELETE FROM lnticket.form WHERE id = ?", (form_id,))

View file

@ -2,21 +2,23 @@ async def m001_initial(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS forms ( CREATE TABLE lnticket.forms (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
costpword INTEGER NOT NULL, costpword INTEGER NOT NULL,
amountmade INTEGER NOT NULL, amountmade INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE lnticket.tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
form TEXT NOT NULL, form TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
@ -24,7 +26,9 @@ async def m001_initial(db):
name TEXT NOT NULL, name TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
sats INTEGER NOT NULL, sats INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
@ -34,7 +38,7 @@ async def m002_changed(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS ticket ( CREATE TABLE lnticket.ticket (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
form TEXT NOT NULL, form TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
@ -43,12 +47,16 @@ async def m002_changed(db):
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
sats INTEGER NOT NULL, sats INTEGER NOT NULL,
paid BOOLEAN NOT NULL, paid BOOLEAN NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]: for row in [
list(row) for row in await db.fetchall("SELECT * FROM lnticket.tickets")
]:
usescsv = "" usescsv = ""
for i in range(row[5]): for i in range(row[5]):
@ -59,7 +67,7 @@ async def m002_changed(db):
usescsv = usescsv[1:] usescsv = usescsv[1:]
await db.execute( await db.execute(
""" """
INSERT INTO ticket ( INSERT INTO lnticket.ticket (
id, id,
form, form,
email, email,
@ -82,14 +90,14 @@ async def m002_changed(db):
True, True,
), ),
) )
await db.execute("DROP TABLE tickets") await db.execute("DROP TABLE lnticket.tickets")
async def m003_changed(db): async def m003_changed(db):
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS form ( CREATE TABLE lnticket.form (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -97,12 +105,14 @@ async def m003_changed(db):
description TEXT NOT NULL, description TEXT NOT NULL,
costpword INTEGER NOT NULL, costpword INTEGER NOT NULL,
amountmade INTEGER NOT NULL, amountmade INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
); );
""" """
) )
for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]: for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.forms")]:
usescsv = "" usescsv = ""
for i in range(row[5]): for i in range(row[5]):
@ -113,7 +123,7 @@ async def m003_changed(db):
usescsv = usescsv[1:] usescsv = usescsv[1:]
await db.execute( await db.execute(
""" """
INSERT INTO form ( INSERT INTO lnticket.form (
id, id,
wallet, wallet,
name, name,
@ -134,4 +144,4 @@ async def m003_changed(db):
row[6], row[6],
), ),
) )
await db.execute("DROP TABLE forms") await db.execute("DROP TABLE lnticket.forms")

View file

@ -0,0 +1,37 @@
import json
import trio # type: ignore
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from .crud import get_ticket, set_ticket_paid
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "lnticket" != payment.extra.get("tag"):
# not a lnticket invoice
return
ticket = await get_ticket(payment.checking_id)
if not ticket:
print("this should never happen", payment)
return
await payment.set_pending(False)
await set_ticket_paid(payment.payment_hash)
_ticket = await get_ticket(payment.checking_id)
print("ticket", _ticket)

View file

@ -33,7 +33,7 @@
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="primary"
:disable="formDialog.data.name == '' || formDialog.data.text == ''" :disable="formDialog.data.name == '' || formDialog.data.text == ''"
type="submit" type="submit"
>Submit</q-btn >Submit</q-btn
@ -77,7 +77,7 @@
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
console.log('{{ form_costpword }}') //console.log('{{ form_costpword }}')
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
new Vue({ new Vue({
@ -99,7 +99,11 @@
show: false, show: false,
status: 'pending', status: 'pending',
paymentReq: null paymentReq: null
} },
wallet: {
inkey: ''
},
cancelListener: () => {}
} }
}, },
computed: { computed: {
@ -128,12 +132,35 @@
}, },
closeReceiveDialog: function () { closeReceiveDialog: function () {
var checker = this.receive.paymentChecker var checker = this.startPaymentNotifier
dismissMsg() dismissMsg()
clearInterval(paymentChecker)
setTimeout(function () {}, 10000) setTimeout(function () {}, 10000)
}, },
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.text = ''
this.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
}
)
},
Invoice: function () { Invoice: function () {
var self = this var self = this
axios axios
@ -158,39 +185,15 @@
status: 'pending', status: 'pending',
paymentReq: self.paymentReq paymentReq: self.paymentReq
} }
paymentChecker = setInterval(function () {
axios
.get('/lnticket/api/v1/tickets/' + self.paymentCheck)
.then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker)
self.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
self.formDialog.data.name = ''
self.formDialog.data.email = ''
self.formDialog.data.text = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, 2000)
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
} }
},
created() {
this.wallet.inkey = '{{form_wallet}}'
this.startPaymentNotifier()
} }
}) })
</script> </script>

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="formDialog.show = true"
>New Form</q-btn >New Form</q-btn
> >
</q-card-section> </q-card-section>
@ -90,6 +90,16 @@
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5> <h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div> </div>
<!-- <div class="col-auto">
<q-btn
flat
color="grey"
icon="autorenew"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="getTickets"
><q-tooltip> Refresh Tickets </q-tooltip></q-btn
>
</div> -->
<div class="col-auto"> <div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV" <q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn >Export to CSV</q-btn
@ -207,7 +217,7 @@
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update Form</q-btn >Update Form</q-btn
> >
@ -215,7 +225,7 @@
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable="formDialog.data.costpword == null || formDialog.data.costpword < 0 || formDialog.data.name == null" :disable="formDialog.data.costpword == null || formDialog.data.costpword < 0 || formDialog.data.name == null"
type="submit" type="submit"
>Create Form</q-btn >Create Form</q-btn
@ -230,7 +240,7 @@
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
var mapLNTicket = function(obj) { const mapLNTicket = function (obj) {
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000), new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
@ -243,7 +253,7 @@
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function() { data: function () {
return { return {
forms: [], forms: [],
tickets: [], tickets: [],
@ -290,11 +300,12 @@
formDialog: { formDialog: {
show: false, show: false,
data: {} data: {}
} },
cancelListener: () => {}
} }
}, },
methods: { methods: {
getTickets: function() { getTickets: function () {
var self = this var self = this
LNbits.api LNbits.api
@ -303,40 +314,43 @@
'/lnticket/api/v1/tickets?all_wallets', '/lnticket/api/v1/tickets?all_wallets',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function(response) { .then(function (response) {
self.tickets = response.data.map(function(obj) { self.tickets = response.data
return mapLNTicket(obj) .map(function (obj) {
}) if (!obj?.paid) return
return mapLNTicket(obj)
})
.filter(v => v)
}) })
}, },
deleteTicket: function(ticketId) { deleteTicket: function (ticketId) {
var self = this var self = this
var tickets = _.findWhere(this.tickets, {id: ticketId}) var tickets = _.findWhere(this.tickets, {id: ticketId})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this ticket') .confirmDialog('Are you sure you want to delete this ticket')
.onOk(function() { .onOk(function () {
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
'/lnticket/api/v1/tickets/' + ticketId, '/lnticket/api/v1/tickets/' + ticketId,
_.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey _.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey
) )
.then(function(response) { .then(function (response) {
self.tickets = _.reject(self.tickets, function(obj) { self.tickets = _.reject(self.tickets, function (obj) {
return obj.id == ticketId return obj.id == ticketId
}) })
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}) })
}, },
exportticketsCSV: function() { exportticketsCSV: function () {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
}, },
getForms: function() { getForms: function () {
var self = this var self = this
LNbits.api LNbits.api
@ -345,16 +359,17 @@
'/lnticket/api/v1/forms?all_wallets', '/lnticket/api/v1/forms?all_wallets',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function(response) { .then(function (response) {
self.forms = response.data.map(function(obj) { self.forms = response.data.map(function (obj) {
return mapLNTicket(obj) return mapLNTicket(obj)
}) })
}) })
}, },
sendFormData: function() { sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, { var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
this.formDialog.data.inkey = wallet.inkey
var data = this.formDialog.data var data = this.formDialog.data
if (data.id) { if (data.id) {
@ -364,22 +379,23 @@
} }
}, },
createForm: function(wallet, data) { createForm: function (wallet, data) {
var self = this var self = this
console.log('create', data)
LNbits.api LNbits.api
.request('POST', '/lnticket/api/v1/forms', wallet.inkey, data) .request('POST', '/lnticket/api/v1/forms', wallet.inkey, data)
.then(function(response) { .then(function (response) {
self.forms.push(mapLNTicket(response.data)) self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false self.formDialog.show = false
self.formDialog.data = {} self.formDialog.data = {}
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
updateformDialog: function(formId) { updateformDialog: function (formId) {
var link = _.findWhere(this.forms, {id: formId}) var link = _.findWhere(this.forms, {id: formId})
console.log(link.id)
this.formDialog.data.id = link.id this.formDialog.data.id = link.id
this.formDialog.data.wallet = link.wallet this.formDialog.data.wallet = link.wallet
this.formDialog.data.name = link.name this.formDialog.data.name = link.name
@ -387,10 +403,9 @@
this.formDialog.data.costpword = link.costpword this.formDialog.data.costpword = link.costpword
this.formDialog.show = true this.formDialog.show = true
}, },
updateForm: function(wallet, data) { updateForm: function (wallet, data) {
var self = this var self = this
console.log(data) console.log('update', data)
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
@ -398,50 +413,67 @@
wallet.inkey, wallet.inkey,
data data
) )
.then(function(response) { .then(function (response) {
self.forms = _.reject(self.forms, function(obj) { self.forms = _.reject(self.forms, function (obj) {
return obj.id == data.id return obj.id == data.id
}) })
self.forms.push(mapLNTicket(response.data)) self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false self.formDialog.show = false
self.formDialog.data = {} self.formDialog.data = {}
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteForm: function(formsId) { deleteForm: function (formsId) {
var self = this var self = this
var forms = _.findWhere(this.forms, {id: formsId}) var forms = _.findWhere(this.forms, {id: formsId})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this form link?') .confirmDialog('Are you sure you want to delete this form link?')
.onOk(function() { .onOk(function () {
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
'/lnticket/api/v1/forms/' + formsId, '/lnticket/api/v1/forms/' + formsId,
_.findWhere(self.g.user.wallets, {id: forms.wallet}).inkey _.findWhere(self.g.user.wallets, {id: forms.wallet}).inkey
) )
.then(function(response) { .then(function (response) {
self.forms = _.reject(self.forms, function(obj) { self.forms = _.reject(self.forms, function (obj) {
return obj.id == formsId return obj.id == formsId
}) })
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}) })
}, },
exportformsCSV: function() { exportformsCSV: function () {
LNbits.utils.exportCSV(this.formsTable.columns, this.forms) LNbits.utils.exportCSV(this.formsTable.columns, this.forms)
},
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.events.onInvoicePaid(
this.g.user.wallets[0],
payment => {
this.getTickets()
this.$q.notify({
type: 'positive',
message: 'New ticket arrived!',
icon: 'textsms'
})
}
)
} }
}, },
created: function() { created: function () {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getForms() this.getForms()
this.startPaymentNotifier()
} }
} }
}) })

View file

@ -1,5 +1,6 @@
from quart import g, abort, render_template from quart import g, abort, render_template
from lnbits.core.crud import get_wallet
from lnbits.decorators import check_user_exists, validate_uuids from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus from http import HTTPStatus
@ -20,10 +21,13 @@ async def display(form_id):
if not form: if not form:
abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.") abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
wallet = await get_wallet(form.wallet)
return await render_template( return await render_template(
"lnticket/display.html", "lnticket/display.html",
form_id=form.id, form_id=form.id,
form_name=form.name, form_name=form.name,
form_desc=form.description, form_desc=form.description,
form_costpword=form.costpword, form_costpword=form.costpword,
form_wallet=wallet.inkey,
) )

View file

@ -18,7 +18,7 @@ async def create_pay_link(
) -> PayLink: ) -> PayLink:
result = await db.execute( result = await db.execute(
""" """
INSERT INTO pay_links ( INSERT INTO lnurlp.pay_links (
wallet, wallet,
description, description,
min, min,
@ -52,7 +52,7 @@ async def create_pay_link(
async def get_pay_link(link_id: int) -> Optional[PayLink]: async def get_pay_link(link_id: int) -> Optional[PayLink]:
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
@ -63,7 +63,7 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f""" f"""
SELECT * FROM pay_links WHERE wallet IN ({q}) SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
ORDER BY Id ORDER BY Id
""", """,
(*wallet_ids,), (*wallet_ids,),
@ -75,20 +75,20 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
) )
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
) )
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
async def delete_pay_link(link_id: int) -> None: async def delete_pay_link(link_id: int) -> None:
await db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))

View file

@ -3,9 +3,9 @@ async def m001_initial(db):
Initial pay table. Initial pay table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE IF NOT EXISTS pay_links ( CREATE TABLE lnurlp.pay_links (
id INTEGER PRIMARY KEY AUTOINCREMENT, id {db.serial_primary_key},
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
@ -20,13 +20,13 @@ async def m002_webhooks_and_success_actions(db):
""" """
Webhooks and success actions. Webhooks and success actions.
""" """
await db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;")
await db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;")
await db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
await db.execute( await db.execute(
""" f"""
CREATE TABLE invoices ( CREATE TABLE lnurlp.invoices (
pay_link INTEGER NOT NULL REFERENCES pay_links (id), pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
payment_hash TEXT NOT NULL, payment_hash TEXT NOT NULL,
webhook_sent INT, -- null means not sent, otherwise store status webhook_sent INT, -- null means not sent, otherwise store status
expiry INT expiry INT
@ -41,12 +41,12 @@ async def m003_min_max_comment_fiat(db):
converted automatically to satoshis based on some API. converted automatically to satoshis based on some API.
""" """
await db.execute( await db.execute(
"ALTER TABLE pay_links ADD COLUMN currency TEXT;" "ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;"
) # null = satoshis ) # null = satoshis
await db.execute( await db.execute(
"ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
) )
await db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;") await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;")
await db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
await db.execute("UPDATE pay_links SET max = min;") await db.execute("UPDATE lnurlp.pay_links SET max = min;")
await db.execute("DROP TABLE invoices") await db.execute("DROP TABLE lnurlp.invoices")

View file

@ -1,4 +1,4 @@
import trio # type: ignore import trio
import json import json
import httpx import httpx

View file

@ -7,7 +7,7 @@
<q-expansion-item group="api" dense expand-separator label="List pay links"> <q-expansion-item group="api" dense expand-separator label="List pay links">
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-blue">GET</span> /api/v1/links</code> <code><span class="text-blue">GET</span> /lnurlp/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
@ -27,7 +27,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<code <code
><span class="text-blue">GET</span> /api/v1/links/&lt;pay_id&gt;</code ><span class="text-blue">GET</span> /lnurlp/api/v1/links/&lt;pay_id&gt;</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
@ -52,11 +52,11 @@
> >
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-green">POST</span> /api/v1/links</code> <code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt; "amount": &lt;integer&gt;}</code> <code>{"description": &lt;string&gt; "amount": &lt;integer&gt; "max": &lt;integer&gt; "min": &lt;integer&gt; "comment_chars": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json) Returns 201 CREATED (application/json)
</h5> </h5>
@ -64,7 +64,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X POST {{ request.url_root }}api/v1/links -d '{"description": >curl -X POST {{ request.url_root }}api/v1/links -d '{"description":
&lt;string&gt;, "amount": &lt;integer&gt;}' -H "Content-type: &lt;string&gt;, "amount": &lt;integer&gt;, "max": &lt;integer&gt;, "min": &lt;integer&gt;, "comment_chars": &lt;integer&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code> </code>
</q-card-section> </q-card-section>
@ -80,7 +80,7 @@
<q-card-section> <q-card-section>
<code <code
><span class="text-green">PUT</span> ><span class="text-green">PUT</span>
/api/v1/links/&lt;pay_id&gt;</code /lnurlp/api/v1/links/&lt;pay_id&gt;</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
@ -111,7 +111,7 @@
<q-card-section> <q-card-section>
<code <code
><span class="text-pink">DELETE</span> ><span class="text-pink">DELETE</span>
/api/v1/links/&lt;pay_id&gt;</code /lnurlp/api/v1/links/&lt;pay_id&gt;</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br /> <code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="formDialog.show = true"
>New pay link</q-btn >New pay link</q-btn
> >
</q-card-section> </q-card-section>
@ -227,14 +227,14 @@
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
unelevated unelevated
color="deep-purple" color="primary"
type="submit" type="submit"
>Update pay link</q-btn >Update pay link</q-btn
> >
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="deep-purple" color="primary"
:disable=" :disable="
formDialog.data.wallet == null || formDialog.data.wallet == null ||
formDialog.data.description == null || formDialog.data.description == null ||

Some files were not shown because too many files have changed in this diff Show more