mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-23 14:40:47 +01:00
Merge branch 'master' into StreamerCopilot
This commit is contained in:
commit
9961551dac
99 changed files with 3473 additions and 452 deletions
|
@ -8,7 +8,7 @@ PORT=5000
|
|||
LNBITS_SITE_TITLE=LNbits
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
LNBITS_DATA_FOLDER="."
|
||||
LNBITS_DATA_FOLDER="./data"
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
|
@ -44,7 +44,9 @@ LND_REST_MACAROON="HEXSTRING"
|
|||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||
# Secret API Key under developers tab
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# LntxbotWallet
|
||||
|
|
1
Pipfile
1
Pipfile
|
@ -35,3 +35,4 @@ pytest = "*"
|
|||
pytest-cov = "*"
|
||||
mypy = "latest"
|
||||
pytest-trio = "*"
|
||||
trio-typing = "*"
|
||||
|
|
443
Pipfile.lock
generated
443
Pipfile.lock
generated
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "e12af74353e8bea3f97bf2aea16a1ba0a6e4c3a08042ce7368187a06e7791e2c"
|
||||
"sha256": "8c4056a80c682fac834266c11892573ce53807226c0810e4564976656ea5ff45"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -18,10 +18,19 @@
|
|||
"default": {
|
||||
"aiofiles": {
|
||||
"hashes": [
|
||||
"sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27",
|
||||
"sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"
|
||||
"sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4",
|
||||
"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": {
|
||||
"hashes": [
|
||||
|
@ -33,11 +42,11 @@
|
|||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
|
||||
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.3.0"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==21.2.0"
|
||||
},
|
||||
"bech32": {
|
||||
"hashes": [
|
||||
|
@ -99,41 +108,40 @@
|
|||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
"sha256:7aff49bc793e58a88ac14bffc3eca0f67e077881d3c62c621679a621294dd174",
|
||||
"sha256:eec10585c33044fb7c69650bc5b68018dac0443753337e2b07684ee0f3c83329"
|
||||
"sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.3"
|
||||
"version": "==1.3.4"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||
"sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
|
||||
"sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
|
||||
],
|
||||
"version": "==2020.12.5"
|
||||
"version": "==2021.5.30"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
|
||||
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==8.0.1"
|
||||
},
|
||||
"ecdsa": {
|
||||
"hashes": [
|
||||
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747",
|
||||
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff"
|
||||
"sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676",
|
||||
"sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.1"
|
||||
"version": "==0.17.0"
|
||||
},
|
||||
"embit": {
|
||||
"hashes": [
|
||||
"sha256:7c4264d7ede8e2c114db10585270874c9df809c68d2e21db918872e3245b5f2b"
|
||||
"sha256:d67fc0f7fbdb7588c3eb24441bf8e05770056260bc8e5537399a1b3ce5ccf12a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
"version": "==0.4.2"
|
||||
},
|
||||
"environs": {
|
||||
"hashes": [
|
||||
|
@ -169,19 +177,19 @@
|
|||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9",
|
||||
"sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"
|
||||
"sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e",
|
||||
"sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.12.3"
|
||||
"version": "==0.13.6"
|
||||
},
|
||||
"httpx": {
|
||||
"hashes": [
|
||||
"sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967",
|
||||
"sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272"
|
||||
"sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c",
|
||||
"sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.17.1"
|
||||
"version": "==0.18.2"
|
||||
},
|
||||
"hypercorn": {
|
||||
"extras": [
|
||||
|
@ -196,34 +204,34 @@
|
|||
},
|
||||
"hyperframe": {
|
||||
"hashes": [
|
||||
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
|
||||
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
|
||||
"sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15",
|
||||
"sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==6.0.0"
|
||||
"version": "==6.0.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
|
||||
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
|
||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
||||
],
|
||||
"version": "==3.1"
|
||||
"version": "==3.2"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
"sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c",
|
||||
"sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.0"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.1"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
|
||||
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
|
||||
"sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
|
||||
"sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.11.3"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.0.1"
|
||||
},
|
||||
"lnurl": {
|
||||
"hashes": [
|
||||
|
@ -235,69 +243,87 @@
|
|||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
|
||||
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
|
||||
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
|
||||
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
|
||||
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
|
||||
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
|
||||
"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"
|
||||
"sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
|
||||
"sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
|
||||
"sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
|
||||
"sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
|
||||
"sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
|
||||
"sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
|
||||
"sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
|
||||
"sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
|
||||
"sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
|
||||
"sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
|
||||
"sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
|
||||
"sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
|
||||
"sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
|
||||
"sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
|
||||
"sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
|
||||
"sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
|
||||
"sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
|
||||
"sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
|
||||
"sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
|
||||
"sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
|
||||
"sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
|
||||
"sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
|
||||
"sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
|
||||
"sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
|
||||
"sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
|
||||
"sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
|
||||
"sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
|
||||
"sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
|
||||
"sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
|
||||
"sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
|
||||
"sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
|
||||
"sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
|
||||
"sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
|
||||
"sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd",
|
||||
"sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"
|
||||
"sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040",
|
||||
"sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.11.1"
|
||||
"version": "==3.12.1"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2",
|
||||
"sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4",
|
||||
"sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8",
|
||||
"sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da",
|
||||
"sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243",
|
||||
"sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb",
|
||||
"sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116",
|
||||
"sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0",
|
||||
"sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76",
|
||||
"sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20",
|
||||
"sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c",
|
||||
"sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1",
|
||||
"sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab",
|
||||
"sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269",
|
||||
"sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2",
|
||||
"sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4",
|
||||
"sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70",
|
||||
"sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9",
|
||||
"sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd",
|
||||
"sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987",
|
||||
"sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21",
|
||||
"sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167",
|
||||
"sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"
|
||||
],
|
||||
"markers": "implementation_name == 'cpython'",
|
||||
"version": "==0.902"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
|
||||
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
|
||||
],
|
||||
"version": "==0.4.3"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
|
@ -316,31 +342,31 @@
|
|||
},
|
||||
"pydantic": {
|
||||
"hashes": [
|
||||
"sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850",
|
||||
"sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f",
|
||||
"sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683",
|
||||
"sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e",
|
||||
"sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3",
|
||||
"sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9",
|
||||
"sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c",
|
||||
"sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f",
|
||||
"sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a",
|
||||
"sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2",
|
||||
"sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125",
|
||||
"sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8",
|
||||
"sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99",
|
||||
"sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f",
|
||||
"sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0",
|
||||
"sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d",
|
||||
"sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520",
|
||||
"sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58",
|
||||
"sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771",
|
||||
"sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4",
|
||||
"sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e",
|
||||
"sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"
|
||||
"sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd",
|
||||
"sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739",
|
||||
"sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f",
|
||||
"sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840",
|
||||
"sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23",
|
||||
"sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287",
|
||||
"sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62",
|
||||
"sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b",
|
||||
"sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb",
|
||||
"sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820",
|
||||
"sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3",
|
||||
"sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b",
|
||||
"sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e",
|
||||
"sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3",
|
||||
"sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316",
|
||||
"sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b",
|
||||
"sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4",
|
||||
"sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20",
|
||||
"sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e",
|
||||
"sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505",
|
||||
"sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1",
|
||||
"sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==1.8.1"
|
||||
"version": "==1.8.2"
|
||||
},
|
||||
"pypng": {
|
||||
"hashes": [
|
||||
|
@ -366,18 +392,18 @@
|
|||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
|
||||
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
|
||||
"sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d",
|
||||
"sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d"
|
||||
],
|
||||
"version": "==0.17.0"
|
||||
"version": "==0.18.0"
|
||||
},
|
||||
"quart": {
|
||||
"hashes": [
|
||||
"sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02",
|
||||
"sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707"
|
||||
"sha256:f35134fb1d81af61624e6d89bca33cd611dcedce2dc4e291f527ab04395f4e1a",
|
||||
"sha256:f80c91d1e0588662483e22dd9c368a5778886b62e128c5399d2cc1b1898482cf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.14.1"
|
||||
"version": "==0.15.1"
|
||||
},
|
||||
"quart-compress": {
|
||||
"hashes": [
|
||||
|
@ -389,19 +415,19 @@
|
|||
},
|
||||
"quart-cors": {
|
||||
"hashes": [
|
||||
"sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573",
|
||||
"sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052"
|
||||
"sha256:c2be932f20413a56b176527090229afe8f725a3ee029d45ea08a174cdc319823",
|
||||
"sha256:ea08d26aef918d59194fbf065cde9b6cae90dc5f21120dcd254d7d46190cd293"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.4.0"
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"quart-trio": {
|
||||
"hashes": [
|
||||
"sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a",
|
||||
"sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1"
|
||||
"sha256:27617f0c9fa8759d3056e9ddcdc038d44093af45eb5f84f8d5714872aaaa8c7d",
|
||||
"sha256:30dfab5e382f06c605d4a5960e8188e8e05d10198f02097f0a16c1dca41b3574"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.7.0"
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"represent": {
|
||||
"hashes": [
|
||||
|
@ -416,18 +442,18 @@
|
|||
"idna2008"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
|
||||
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
|
||||
"sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
|
||||
"sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
"version": "==1.5.0"
|
||||
},
|
||||
"secure": {
|
||||
"hashes": [
|
||||
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
|
||||
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
|
||||
"sha256:6e30939d8f95bf3b8effb8a36ebb5ed57f265daeeae905e3aa9677ea538ab64e",
|
||||
"sha256:a93b720c7614809c131ca80e477263140107c6c212829d0a6e1f7bc8d859c608"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"shortuuid": {
|
||||
"hashes": [
|
||||
|
@ -439,11 +465,11 @@
|
|||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||
],
|
||||
"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": {
|
||||
"hashes": [
|
||||
|
@ -455,10 +481,10 @@
|
|||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
|
||||
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
|
||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||
],
|
||||
"version": "==2.3.0"
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"sqlalchemy": {
|
||||
"hashes": [
|
||||
|
@ -528,22 +554,30 @@
|
|||
"index": "pypi",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"trio-typing": {
|
||||
"hashes": [
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
"sha256:35f1bec8df2150feab6c8b073b54135321722c9d9289bbffa78a9a091ea83b72",
|
||||
"sha256:f2007df617a6c26a2294db0dd63645b5451149757e1bde4cb8dbf3e1369174fb"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
|
||||
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
|
||||
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.10.0.0"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
|
||||
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"
|
||||
"sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42",
|
||||
"sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==1.0.1"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.1"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
|
@ -572,11 +606,11 @@
|
|||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
|
||||
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.3.0"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==21.2.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
|
@ -587,11 +621,11 @@
|
|||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
|
||||
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==8.0.1"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
|
@ -653,10 +687,10 @@
|
|||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
|
||||
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
|
||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
||||
],
|
||||
"version": "==3.1"
|
||||
"version": "==3.2"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
|
@ -667,31 +701,32 @@
|
|||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e",
|
||||
"sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064",
|
||||
"sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c",
|
||||
"sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4",
|
||||
"sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97",
|
||||
"sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df",
|
||||
"sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8",
|
||||
"sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a",
|
||||
"sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56",
|
||||
"sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7",
|
||||
"sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6",
|
||||
"sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5",
|
||||
"sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a",
|
||||
"sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521",
|
||||
"sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564",
|
||||
"sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49",
|
||||
"sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66",
|
||||
"sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a",
|
||||
"sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119",
|
||||
"sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506",
|
||||
"sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c",
|
||||
"sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"
|
||||
"sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2",
|
||||
"sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4",
|
||||
"sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8",
|
||||
"sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da",
|
||||
"sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243",
|
||||
"sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb",
|
||||
"sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116",
|
||||
"sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0",
|
||||
"sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76",
|
||||
"sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20",
|
||||
"sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c",
|
||||
"sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1",
|
||||
"sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab",
|
||||
"sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269",
|
||||
"sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2",
|
||||
"sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4",
|
||||
"sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70",
|
||||
"sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9",
|
||||
"sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd",
|
||||
"sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987",
|
||||
"sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21",
|
||||
"sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167",
|
||||
"sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.812"
|
||||
"markers": "implementation_name == 'cpython'",
|
||||
"version": "==0.902"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
|
@ -749,19 +784,19 @@
|
|||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634",
|
||||
"sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"
|
||||
"sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b",
|
||||
"sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.2.3"
|
||||
"version": "==6.2.4"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7",
|
||||
"sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"
|
||||
"sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a",
|
||||
"sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.11.1"
|
||||
"version": "==2.12.1"
|
||||
},
|
||||
"pytest-trio": {
|
||||
"hashes": [
|
||||
|
@ -826,10 +861,10 @@
|
|||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
|
||||
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
|
||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||
],
|
||||
"version": "==2.3.0"
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
|
@ -884,12 +919,12 @@
|
|||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
|
||||
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
|
||||
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
"version": "==3.10.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
|
|||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
|
||||
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
|
||||
|
||||
See [lnbits.org](https://lnbits.org) for more detailed documentation.
|
||||
|
||||
|
|
|
@ -5,15 +5,11 @@ title: Installation
|
|||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
# Installation
|
||||
|
||||
Download the latest stable release https://github.com/lnbits/lnbits/releases
|
||||
|
||||
|
||||
Application dependencies
|
||||
------------------------
|
||||
## Application dependencies
|
||||
|
||||
The application uses [Pipenv][pipenv] to manage Python packages.
|
||||
While in development, you will need to install all dependencies:
|
||||
|
@ -24,7 +20,7 @@ $ pipenv install --dev
|
|||
```
|
||||
|
||||
If any of the modules fails to install, try checking and upgrading your setupTool module.
|
||||
`pip install -U setuptools`
|
||||
`pip install -U setuptools`
|
||||
|
||||
If you wish to use a version of Python higher than 3.7:
|
||||
|
||||
|
@ -41,22 +37,33 @@ E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `
|
|||
|
||||
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
|
||||
Running the server
|
||||
------------------
|
||||
## Running the 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
|
||||
$ pipenv run python -m lnbits
|
||||
```
|
||||
|
||||
Frontend
|
||||
--------
|
||||
**Note**: You'll need to use _https_ for some endpoints and/or extensions. You can use [ngrok](https://ngrok.com/) for that. Follow the installation instructions on the website and when it's all set you can run:
|
||||
|
||||
```sh
|
||||
$ ./nrok http 5000
|
||||
```
|
||||
|
||||
this will give you an _https_ tunnel for the _localhost_, use that URL for navigating to LNBits.
|
||||
|
||||
## Frontend
|
||||
|
||||
The frontend uses [Vue.js and Quasar][quasar].
|
||||
|
||||
|
||||
[quart]: https://pgjones.gitlab.io/
|
||||
[pipenv]: https://pipenv.pypa.io/
|
||||
[polar]: https://lightningpolar.com/
|
||||
|
|
|
@ -13,18 +13,48 @@ Download this repo and install the dependencies:
|
|||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' should work
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
./venv/bin/quart assets
|
||||
./venv/bin/quart migrate
|
||||
./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.
|
||||
|
||||
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.
|
||||
|
||||
Docker installation
|
||||
===================
|
||||
|
||||
To install using docker you first need to build the docker image as:
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/ # ${PWD} referred as <lnbits_repo>
|
||||
docker build -t lnbits .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
```
|
||||
cp <lnbits_repo>/.env.example .env
|
||||
```
|
||||
and change the configuration in `.env` as required.
|
||||
|
||||
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
|
||||
```
|
||||
mkdir data
|
||||
sudo chown 1000:1000 ./data/
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
|
|
@ -54,7 +54,7 @@ Using this wallet requires the installation of the `lndgrpc` and `purerpc` Pytho
|
|||
|
||||
### LNPay
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
|
||||
from .commands import migrate_databases, transpile_scss, bundle_vendored
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import sys
|
||||
import importlib
|
||||
import warnings
|
||||
import importlib
|
||||
import traceback
|
||||
|
||||
from quart import g
|
||||
from quart_trio import QuartTrio
|
||||
|
@ -23,7 +24,6 @@ from .tasks import (
|
|||
invoice_listener,
|
||||
internal_invoice_listener,
|
||||
webhook_handler,
|
||||
grab_app_for_later,
|
||||
)
|
||||
from .settings import WALLET
|
||||
|
||||
|
@ -48,7 +48,7 @@ def create_app(config_object="lnbits.settings") -> QuartTrio:
|
|||
register_commands(app)
|
||||
register_request_hooks(app)
|
||||
register_async_tasks(app)
|
||||
grab_app_for_later(app)
|
||||
register_exception_handlers(app)
|
||||
|
||||
return app
|
||||
|
||||
|
@ -108,16 +108,13 @@ def register_assets(app: QuartTrio):
|
|||
def register_filters(app: QuartTrio):
|
||||
"""Jinja filters."""
|
||||
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
|
||||
app.jinja_env.globals["LNBITS_VERSION"] = app.config["LNBITS_COMMIT"]
|
||||
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.before_request
|
||||
async def before_request():
|
||||
g.nursery = app.nursery
|
||||
|
||||
@app.after_request
|
||||
async def set_secure_headers(response):
|
||||
secure_headers.quart(response)
|
||||
|
@ -139,3 +136,21 @@ def register_async_tasks(app):
|
|||
@app.after_serving
|
||||
async def stop_listeners():
|
||||
pass
|
||||
|
||||
|
||||
def register_exception_handlers(app):
|
||||
@app.errorhandler(Exception)
|
||||
async def basic_error(err):
|
||||
etype, value, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
return (
|
||||
"\n\n".join(
|
||||
[
|
||||
"LNbits internal error!",
|
||||
exc,
|
||||
"If you believe this shouldn't be an error please bring it up on https://t.me/lnbits",
|
||||
]
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import warnings
|
||||
import click
|
||||
import importlib
|
||||
|
|
|
@ -14,6 +14,7 @@ core_app: Blueprint = Blueprint(
|
|||
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.public_api import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
|
|
@ -267,12 +267,23 @@ async def get_payments(
|
|||
async def delete_expired_invoices(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 2592000
|
||||
"""
|
||||
)
|
||||
|
||||
# then we delete all expired invoices, checking one by one
|
||||
rows = await (conn or db).fetchall(
|
||||
"""
|
||||
SELECT bolt11
|
||||
FROM apipayments
|
||||
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 86400
|
||||
"""
|
||||
WHERE pending = 1
|
||||
AND bolt11 IS NOT NULL
|
||||
AND amount > 0 AND time < strftime('%s', 'now') - 86400
|
||||
"""
|
||||
)
|
||||
for (payment_request,) in rows:
|
||||
try:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
|
@ -229,10 +229,10 @@ async def redeem_lnurl_withdraw(
|
|||
pass
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(
|
||||
res["callback"],
|
||||
params=params,
|
||||
)
|
||||
try:
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
|
|
|
@ -119,6 +119,8 @@ new Vue({
|
|||
paymentHash: null,
|
||||
minMax: [0, 2100000000000000],
|
||||
lnurl: null,
|
||||
units: ['sat'],
|
||||
unit: 'sat',
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
|
@ -233,6 +235,7 @@ new Vue({
|
|||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = null
|
||||
this.receive.data.memo = null
|
||||
this.receive.unit = 'sat'
|
||||
this.receive.paymentChecker = null
|
||||
this.receive.minMax = [0, 2100000000000000]
|
||||
this.receive.lnurl = null
|
||||
|
@ -269,11 +272,13 @@ new Vue({
|
|||
},
|
||||
createInvoice: function () {
|
||||
this.receive.status = 'loading'
|
||||
|
||||
LNbits.api
|
||||
.createInvoice(
|
||||
this.g.wallet,
|
||||
this.receive.data.amount,
|
||||
this.receive.data.memo,
|
||||
this.receive.unit,
|
||||
this.receive.lnurl && this.receive.lnurl.callback
|
||||
)
|
||||
.then(response => {
|
||||
|
@ -619,6 +624,15 @@ new Vue({
|
|||
created: function () {
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.receive.units = ['sat', ...response.data]
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
|
@ -8,7 +8,7 @@ from . import db
|
|||
from .crud import get_balance_notify
|
||||
from .models import Payment
|
||||
|
||||
sse_listeners: List[trio.MemorySendChannel] = []
|
||||
api_invoice_listeners: List[trio.MemorySendChannel] = []
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
|
@ -20,7 +20,7 @@ async def register_listeners():
|
|||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
# send information to sse channel
|
||||
await dispatch_sse(payment)
|
||||
await dispatch_invoice_listener(payment)
|
||||
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
|
@ -40,13 +40,13 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
|||
pass
|
||||
|
||||
|
||||
async def dispatch_sse(payment: Payment):
|
||||
for send_channel in sse_listeners:
|
||||
async def dispatch_invoice_listener(payment: Payment):
|
||||
for send_channel in api_invoice_listeners:
|
||||
try:
|
||||
send_channel.send_nowait(payment)
|
||||
except trio.WouldBlock:
|
||||
print("removing sse listener", send_channel)
|
||||
sse_listeners.remove(send_channel)
|
||||
api_invoice_listeners.remove(send_channel)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
|
|
|
@ -129,7 +129,7 @@
|
|||
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
|
||||
<a
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag, '?usr=', user.id].join('')"
|
||||
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
||||
>
|
||||
#{{ props.row.tag }}
|
||||
</a>
|
||||
|
@ -313,12 +313,21 @@
|
|||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
||||
</p>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
:label="`Amount (${receive.unit}) *`"
|
||||
:step="receive.unit != 'sat' ? '0.001' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import lnurl # type: ignore
|
||||
import httpx
|
||||
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
||||
from quart import g, jsonify, make_response, url_for
|
||||
from quart import g, current_app, jsonify, make_response, url_for
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict, Union
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
||||
|
||||
from .. import core_app, db
|
||||
from ..crud import save_balance_check
|
||||
|
@ -20,7 +21,7 @@ from ..services import (
|
|||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import sse_listeners
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
||||
@core_app.route("/api/v1/wallet", methods=["GET"])
|
||||
|
@ -47,13 +48,14 @@ async def api_payments():
|
|||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"amount": {"type": "integer", "min": 1, "required": True},
|
||||
"amount": {"type": "number", "min": 0.001, "required": True},
|
||||
"memo": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
"required": True,
|
||||
"excludes": "description_hash",
|
||||
},
|
||||
"unit": {"type": "string", "empty": False, "required": False},
|
||||
"description_hash": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
|
@ -74,11 +76,17 @@ async def api_payments_create_invoice():
|
|||
description_hash = b""
|
||||
memo = g.data["memo"]
|
||||
|
||||
if g.data.get("unit") or "sat" == "sat":
|
||||
amount = g.data["amount"]
|
||||
else:
|
||||
price_in_sats = await fiat_amount_as_satoshis(g.data["amount"], g.data["unit"])
|
||||
amount = price_in_sats
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
amount=g.data["amount"],
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
extra=g.data.get("extra"),
|
||||
|
@ -295,7 +303,7 @@ async def api_payments_sse():
|
|||
send_payment, receive_payment = trio.open_memory_channel(0)
|
||||
|
||||
print("adding sse listener", send_payment)
|
||||
sse_listeners.append(send_payment)
|
||||
api_invoice_listeners.append(send_payment)
|
||||
|
||||
send_event, event_to_send = trio.open_memory_channel(0)
|
||||
|
||||
|
@ -310,8 +318,8 @@ async def api_payments_sse():
|
|||
await send_event.send(("keepalive", ""))
|
||||
await trio.sleep(25)
|
||||
|
||||
g.nursery.start_soon(payment_received)
|
||||
g.nursery.start_soon(repeat_keepalive)
|
||||
current_app.nursery.start_soon(payment_received)
|
||||
current_app.nursery.start_soon(repeat_keepalive)
|
||||
|
||||
async def send_events():
|
||||
try:
|
||||
|
@ -435,3 +443,8 @@ async def api_perform_lnurlauth():
|
|||
if err:
|
||||
return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@core_app.route("/api/v1/currencies", methods=["GET"])
|
||||
async def api_list_currencies_available():
|
||||
return jsonify(list(currencies.keys()))
|
||||
|
|
|
@ -2,6 +2,7 @@ from os import path
|
|||
from http import HTTPStatus
|
||||
from quart import (
|
||||
g,
|
||||
current_app,
|
||||
abort,
|
||||
jsonify,
|
||||
request,
|
||||
|
@ -128,7 +129,7 @@ async def lnurl_full_withdraw():
|
|||
_external=True,
|
||||
),
|
||||
"k1": "0",
|
||||
"minWithdrawable": 1 if wallet.withdrawable_balance else 0,
|
||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
|
||||
"balanceCheck": url_for(
|
||||
|
@ -152,9 +153,12 @@ async def lnurl_full_withdraw_callback():
|
|||
pr = request.args.get("pr")
|
||||
|
||||
async def pay():
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
try:
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
except:
|
||||
pass
|
||||
|
||||
g.nursery.start_soon(pay)
|
||||
current_app.nursery.start_soon(pay)
|
||||
|
||||
balance_notify = request.args.get("balanceNotify")
|
||||
if balance_notify:
|
||||
|
@ -197,7 +201,7 @@ async def lnurlwallet():
|
|||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
|
||||
g.nursery.start_soon(
|
||||
current_app.nursery.start_soon(
|
||||
redeem_lnurl_withdraw,
|
||||
wallet.id,
|
||||
request.args.get("lightning"),
|
||||
|
|
37
lnbits/core/views/public_api.py
Normal file
37
lnbits/core/views/public_api.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import trio
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
from quart import jsonify
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
||||
@core_app.route("/public/v1/payment/<payment_hash>", methods=["GET"])
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
if not payment:
|
||||
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
||||
elif not payment.pending:
|
||||
return jsonify({"status": "paid"}), HTTPStatus.OK
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration < datetime.datetime.now():
|
||||
return jsonify({"status": "expired"}), HTTPStatus.OK
|
||||
except:
|
||||
return jsonify({"message": "Invalid bolt11 invoice."}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
send_payment, receive_payment = trio.open_memory_channel(0)
|
||||
|
||||
print("adding standalone invoice listener", payment_hash, send_payment)
|
||||
api_invoice_listeners.append(send_payment)
|
||||
|
||||
async for payment in receive_payment:
|
||||
if payment.payment_hash == payment_hash:
|
||||
return jsonify({"status": "paid"}), HTTPStatus.OK
|
|
@ -3,7 +3,7 @@ import trio
|
|||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import create_engine # 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
|
||||
|
||||
|
|
|
@ -1,3 +1,33 @@
|
|||
<h1>Events</h1>
|
||||
<h2>Events: Sell and register event tickets</h2>
|
||||
Events alows you to make a wave of tickets for an event, each ticket is in the form of a unqiue QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.
|
||||
# Events
|
||||
|
||||
## Sell tickets for events and use the built-in scanner for registering attendants
|
||||
|
||||
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
|
||||
|
||||
Events includes a shareable ticket scanner, which can be used to register attendees.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an event\
|
||||

|
||||
2. Fill out the event information:
|
||||
|
||||
- event name
|
||||
- wallet (normally there's only one)
|
||||
- event information
|
||||
- closing date for event registration
|
||||
- begin and end date of the event
|
||||
|
||||

|
||||
|
||||
3. Share the event registration link\
|
||||

|
||||
|
||||
- ticket example\
|
||||

|
||||
|
||||
- QR code ticket, presented after invoice paid, to present at registration\
|
||||

|
||||
|
||||
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
||||

|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
var mapEvents = function (obj) {
|
||||
var mapEvents = function(obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
|
@ -94,7 +94,7 @@
|
|||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
data: function() {
|
||||
return {
|
||||
tickets: [],
|
||||
ticketsTable: {
|
||||
|
@ -119,35 +119,35 @@
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
hoverEmail: function (tmp) {
|
||||
hoverEmail: function(tmp) {
|
||||
this.tickets.data.emailtemp = tmp
|
||||
},
|
||||
closeCamera: function () {
|
||||
closeCamera: function() {
|
||||
this.sendCamera.show = false
|
||||
},
|
||||
showCamera: function () {
|
||||
showCamera: function() {
|
||||
this.sendCamera.show = true
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
decodeQR: function(res) {
|
||||
this.sendCamera.show = false
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/register/ticket/' + res)
|
||||
.then(function (response) {
|
||||
.then(function(response) {
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Registered!'
|
||||
})
|
||||
setTimeout(function () {
|
||||
setTimeout(function() {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getEventTickets: function () {
|
||||
getEventTickets: function() {
|
||||
var self = this
|
||||
console.log('obj')
|
||||
LNbits.api
|
||||
|
@ -155,17 +155,17 @@
|
|||
'GET',
|
||||
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
.then(function(response) {
|
||||
self.tickets = response.data.map(function(obj) {
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
created: function() {
|
||||
this.getEventTickets()
|
||||
}
|
||||
})
|
||||
|
|
|
@ -195,6 +195,9 @@ async def api_event_register_ticket(ticket_id):
|
|||
if not ticket:
|
||||
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
if not ticket.paid:
|
||||
return jsonify({"message": "Ticket not paid for."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
if ticket.registered == True:
|
||||
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
|
||||
|
||||
|
|
5
lnbits/extensions/jukebox/README.md
Normal file
5
lnbits/extensions/jukebox/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# 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
|
||||
|
||||
Select the playlists you want people to be able to pay for, share the frontend page, profit :)
|
12
lnbits/extensions/jukebox/__init__.py
Normal file
12
lnbits/extensions/jukebox/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_jukebox")
|
||||
|
||||
jukebox_ext: Blueprint = Blueprint(
|
||||
"jukebox", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
6
lnbits/extensions/jukebox/config.json
Normal file
6
lnbits/extensions/jukebox/config.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "SpotifyJukebox",
|
||||
"short_description": "Spotify jukebox middleware",
|
||||
"icon": "radio",
|
||||
"contributors": ["benarc"]
|
||||
}
|
122
lnbits/extensions/jukebox/crud.py
Normal file
122
lnbits/extensions/jukebox/crud.py
Normal file
|
@ -0,0 +1,122 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from . import db
|
||||
from .models import Jukebox, JukeboxPayment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
async def create_jukebox(
|
||||
inkey: str,
|
||||
user: str,
|
||||
wallet: str,
|
||||
title: str,
|
||||
price: int,
|
||||
sp_user: str,
|
||||
sp_secret: str,
|
||||
sp_access_token: Optional[str] = "",
|
||||
sp_refresh_token: Optional[str] = "",
|
||||
sp_device: Optional[str] = "",
|
||||
sp_playlists: Optional[str] = "",
|
||||
) -> Jukebox:
|
||||
juke_id = urlsafe_short_hash()
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
juke_id,
|
||||
user,
|
||||
title,
|
||||
wallet,
|
||||
sp_user,
|
||||
sp_secret,
|
||||
sp_access_token,
|
||||
sp_refresh_token,
|
||||
sp_device,
|
||||
sp_playlists,
|
||||
int(price),
|
||||
0,
|
||||
),
|
||||
)
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
assert jukebox, "Newly created Jukebox couldn't be retrieved"
|
||||
return jukebox
|
||||
|
||||
|
||||
async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,))
|
||||
return Jukebox(**row) if row else None
|
||||
|
||||
|
||||
async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
|
||||
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,))
|
||||
return Jukebox(**row) if row else None
|
||||
|
||||
|
||||
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
|
||||
row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (user,))
|
||||
return Jukebox(**row) if row else None
|
||||
|
||||
|
||||
async def get_jukeboxs(user: str) -> List[Jukebox]:
|
||||
rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,))
|
||||
for row in rows:
|
||||
if row.sp_playlists == "":
|
||||
await delete_jukebox(row.id)
|
||||
rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,))
|
||||
return [Jukebox.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_jukebox(juke_id: str):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM jukebox WHERE id = ?
|
||||
""",
|
||||
(juke_id),
|
||||
)
|
||||
|
||||
|
||||
#####################################PAYMENTS
|
||||
|
||||
|
||||
async def create_jukebox_payment(
|
||||
song_id: str, payment_hash: str, juke_id: str
|
||||
) -> JukeboxPayment:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO jukebox_payment (payment_hash, juke_id, song_id, paid)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payment_hash,
|
||||
juke_id,
|
||||
song_id,
|
||||
False,
|
||||
),
|
||||
)
|
||||
jukebox_payment = await get_jukebox_payment(payment_hash)
|
||||
assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved"
|
||||
return jukebox_payment
|
||||
|
||||
|
||||
async def update_jukebox_payment(
|
||||
payment_hash: str, **kwargs
|
||||
) -> Optional[JukeboxPayment]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE jukebox_payment SET {q} WHERE payment_hash = ?",
|
||||
(*kwargs.values(), payment_hash),
|
||||
)
|
||||
return await get_jukebox_payment(payment_hash)
|
||||
|
||||
|
||||
async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM jukebox_payment WHERE payment_hash = ?", (payment_hash,)
|
||||
)
|
||||
return JukeboxPayment(**row) if row else None
|
39
lnbits/extensions/jukebox/migrations.py
Normal file
39
lnbits/extensions/jukebox/migrations.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial jukebox table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE jukebox (
|
||||
id TEXT PRIMARY KEY,
|
||||
user TEXT,
|
||||
title TEXT,
|
||||
wallet TEXT,
|
||||
inkey TEXT,
|
||||
sp_user TEXT NOT NULL,
|
||||
sp_secret TEXT NOT NULL,
|
||||
sp_access_token TEXT,
|
||||
sp_refresh_token TEXT,
|
||||
sp_device TEXT,
|
||||
sp_playlists TEXT,
|
||||
price INTEGER,
|
||||
profit INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_initial(db):
|
||||
"""
|
||||
Initial jukebox_payment table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE jukebox_payment (
|
||||
payment_hash TEXT PRIMARY KEY,
|
||||
juke_id TEXT,
|
||||
song_id TEXT,
|
||||
paid BOOL
|
||||
);
|
||||
"""
|
||||
)
|
33
lnbits/extensions/jukebox/models.py
Normal file
33
lnbits/extensions/jukebox/models.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
from typing import NamedTuple
|
||||
from sqlite3 import Row
|
||||
|
||||
|
||||
class Jukebox(NamedTuple):
|
||||
id: str
|
||||
user: str
|
||||
title: str
|
||||
wallet: str
|
||||
inkey: str
|
||||
sp_user: str
|
||||
sp_secret: str
|
||||
sp_access_token: str
|
||||
sp_refresh_token: str
|
||||
sp_device: str
|
||||
sp_playlists: str
|
||||
price: int
|
||||
profit: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Jukebox":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class JukeboxPayment(NamedTuple):
|
||||
payment_hash: str
|
||||
juke_id: str
|
||||
song_id: str
|
||||
paid: bool
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "JukeboxPayment":
|
||||
return cls(**dict(row))
|
422
lnbits/extensions/jukebox/static/js/index.js
Normal file
422
lnbits/extensions/jukebox/static/js/index.js
Normal file
|
@ -0,0 +1,422 @@
|
|||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapJukebox = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.sp_id = obj.id
|
||||
obj.device = obj.sp_device.split('-')[0]
|
||||
playlists = obj.sp_playlists.split(',')
|
||||
var i
|
||||
playlistsar = []
|
||||
for (i = 0; i < playlists.length; i++) {
|
||||
playlistsar.push(playlists[i].split('-')[0])
|
||||
}
|
||||
obj.playlist = playlistsar.join()
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
JukeboxTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'device',
|
||||
align: 'left',
|
||||
label: 'Device',
|
||||
field: 'device'
|
||||
},
|
||||
{
|
||||
name: 'playlist',
|
||||
align: 'left',
|
||||
label: 'Playlist',
|
||||
field: 'playlist'
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
align: 'left',
|
||||
label: 'Price',
|
||||
field: 'price'
|
||||
},
|
||||
{
|
||||
name: 'profit',
|
||||
align: 'left',
|
||||
label: 'Profit',
|
||||
field: 'profit'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
isPwd: true,
|
||||
tokenFetched: true,
|
||||
devices: [],
|
||||
filter: '',
|
||||
jukebox: {},
|
||||
playlists: [],
|
||||
JukeboxLinks: [],
|
||||
step: 1,
|
||||
locationcbPath: '',
|
||||
locationcb: '',
|
||||
jukeboxDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
spotifyDialog: false,
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var link = _.findWhere(this.JukeboxLinks, {id: linkId})
|
||||
|
||||
this.qrCodeDialog.data = _.clone(link)
|
||||
console.log(this.qrCodeDialog.data)
|
||||
this.qrCodeDialog.data.url =
|
||||
window.location.protocol + '//' + window.location.host
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
getJukeboxes() {
|
||||
self = this
|
||||
LNbits.api
|
||||
.request('GET', '/jukebox/api/v1/jukebox', self.g.user.wallets[0].adminkey)
|
||||
.then(function (response) {
|
||||
self.JukeboxLinks = response.data.map(mapJukebox)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteJukebox(juke_id) {
|
||||
self = this
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this Jukebox?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/jukebox/api/v1/jukebox/' + juke_id,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.JukeboxLinks = _.reject(self.JukeboxLinks, function (obj) {
|
||||
return obj.id === juke_id
|
||||
})
|
||||
})
|
||||
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateJukebox: function (linkId) {
|
||||
self = this
|
||||
var link = _.findWhere(self.JukeboxLinks, {id: linkId})
|
||||
self.jukeboxDialog.data = _.clone(link._data)
|
||||
console.log(this.jukeboxDialog.data.sp_access_token)
|
||||
|
||||
self.refreshDevices()
|
||||
self.refreshPlaylists()
|
||||
|
||||
self.step = 4
|
||||
self.jukeboxDialog.data.sp_device = []
|
||||
self.jukeboxDialog.data.sp_playlists = []
|
||||
self.jukeboxDialog.data.sp_id = self.jukeboxDialog.data.id
|
||||
self.jukeboxDialog.data.price = String(self.jukeboxDialog.data.price)
|
||||
self.jukeboxDialog.show = true
|
||||
},
|
||||
closeFormDialog() {
|
||||
this.jukeboxDialog.data = {}
|
||||
this.jukeboxDialog.show = false
|
||||
this.step = 1
|
||||
},
|
||||
submitSpotifyKeys() {
|
||||
self = this
|
||||
self.jukeboxDialog.data.user = self.g.user.id
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/jukebox/api/v1/jukebox/',
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.jukeboxDialog.data
|
||||
)
|
||||
.then(response => {
|
||||
if (response.data) {
|
||||
self.jukeboxDialog.data.sp_id = response.data.id
|
||||
self.step = 3
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
authAccess() {
|
||||
self = this
|
||||
self.requestAuthorization()
|
||||
self.getSpotifyTokens()
|
||||
self.$q.notify({
|
||||
spinner: true,
|
||||
message: 'Processing',
|
||||
timeout: 10000
|
||||
})
|
||||
},
|
||||
getSpotifyTokens() {
|
||||
self = this
|
||||
var counter = 0
|
||||
var timerId = setInterval(function () {
|
||||
counter++
|
||||
if (!self.jukeboxDialog.data.sp_user) {
|
||||
clearInterval(timerId)
|
||||
}
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
if (response.data.sp_access_token) {
|
||||
self.fetchAccessToken(response.data.sp_access_token)
|
||||
if (self.jukeboxDialog.data.sp_access_token) {
|
||||
self.refreshPlaylists()
|
||||
self.refreshDevices()
|
||||
console.log("this.devices")
|
||||
console.log(self.devices)
|
||||
console.log("this.devices")
|
||||
setTimeout(function () {
|
||||
if (self.devices.length < 1 || self.playlists.length < 1) {
|
||||
self.$q.notify({
|
||||
spinner: true,
|
||||
color: 'red',
|
||||
message:
|
||||
'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
|
||||
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.utils.notifyApiError(err)
|
||||
})
|
||||
clearInterval(timerId)
|
||||
self.closeFormDialog()
|
||||
} else {
|
||||
self.step = 4
|
||||
clearInterval(timerId)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}, 3000)
|
||||
},
|
||||
requestAuthorization() {
|
||||
self = this
|
||||
var url = 'https://accounts.spotify.com/authorize'
|
||||
url += '?client_id=' + self.jukeboxDialog.data.sp_user
|
||||
url += '&response_type=code'
|
||||
url +=
|
||||
'&redirect_uri=' +
|
||||
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
|
||||
url += '&show_dialog=true'
|
||||
url +=
|
||||
'&scope=user-read-private user-read-email user-modify-playback-state user-read-playback-position user-library-read streaming user-read-playback-state user-read-recently-played playlist-read-private'
|
||||
|
||||
window.open(url)
|
||||
},
|
||||
openNewDialog() {
|
||||
this.jukeboxDialog.show = true
|
||||
this.jukeboxDialog.data = {}
|
||||
},
|
||||
createJukebox() {
|
||||
self = this
|
||||
self.jukeboxDialog.data.sp_playlists = self.jukeboxDialog.data.sp_playlists.join()
|
||||
self.updateDB()
|
||||
self.jukeboxDialog.show = false
|
||||
self.getJukeboxes()
|
||||
},
|
||||
updateDB() {
|
||||
self = this
|
||||
console.log(self.jukeboxDialog.data)
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id,
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.jukeboxDialog.data
|
||||
)
|
||||
.then(function (response) {
|
||||
console.log(response.data)
|
||||
if (
|
||||
self.jukeboxDialog.data.sp_playlists &&
|
||||
self.jukeboxDialog.data.sp_devices
|
||||
) {
|
||||
self.getJukeboxes()
|
||||
// self.JukeboxLinks.push(mapJukebox(response.data))
|
||||
}
|
||||
})
|
||||
},
|
||||
playlistApi(method, url, body) {
|
||||
self = this
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.open(method, url, true)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Bearer ' + this.jukeboxDialog.data.sp_access_token
|
||||
)
|
||||
xhr.send(body)
|
||||
xhr.onload = function () {
|
||||
if (xhr.status == 401) {
|
||||
self.refreshAccessToken()
|
||||
self.playlistApi(
|
||||
'GET',
|
||||
'https://api.spotify.com/v1/me/playlists',
|
||||
null
|
||||
)
|
||||
}
|
||||
let responseObj = JSON.parse(xhr.response)
|
||||
self.jukeboxDialog.data.playlists = null
|
||||
self.playlists = []
|
||||
self.jukeboxDialog.data.playlists = []
|
||||
var i
|
||||
for (i = 0; i < responseObj.items.length; i++) {
|
||||
self.playlists.push(
|
||||
responseObj.items[i].name + '-' + responseObj.items[i].id
|
||||
)
|
||||
}
|
||||
console.log(self.playlists)
|
||||
}
|
||||
},
|
||||
refreshPlaylists() {
|
||||
self = this
|
||||
self.playlistApi('GET', 'https://api.spotify.com/v1/me/playlists', null)
|
||||
},
|
||||
deviceApi(method, url, body) {
|
||||
self = this
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.open(method, url, true)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Bearer ' + this.jukeboxDialog.data.sp_access_token
|
||||
)
|
||||
xhr.send(body)
|
||||
xhr.onload = function () {
|
||||
if (xhr.status == 401) {
|
||||
self.refreshAccessToken()
|
||||
self.deviceApi(
|
||||
'GET',
|
||||
'https://api.spotify.com/v1/me/player/devices',
|
||||
null
|
||||
)
|
||||
}
|
||||
let responseObj = JSON.parse(xhr.response)
|
||||
self.jukeboxDialog.data.devices = []
|
||||
|
||||
self.devices = []
|
||||
var i
|
||||
for (i = 0; i < responseObj.devices.length; i++) {
|
||||
self.devices.push(
|
||||
responseObj.devices[i].name + '-' + responseObj.devices[i].id
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
refreshDevices() {
|
||||
self = this
|
||||
self.deviceApi(
|
||||
'GET',
|
||||
'https://api.spotify.com/v1/me/player/devices',
|
||||
null
|
||||
)
|
||||
},
|
||||
fetchAccessToken(code) {
|
||||
self = this
|
||||
let body = 'grant_type=authorization_code'
|
||||
body += '&code=' + code
|
||||
body +=
|
||||
'&redirect_uri=' +
|
||||
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
|
||||
|
||||
self.callAuthorizationApi(body)
|
||||
},
|
||||
refreshAccessToken() {
|
||||
self = this
|
||||
let body = 'grant_type=refresh_token'
|
||||
body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token
|
||||
body += '&client_id=' + self.jukeboxDialog.data.sp_user
|
||||
self.callAuthorizationApi(body)
|
||||
},
|
||||
callAuthorizationApi(body) {
|
||||
self = this
|
||||
console.log(
|
||||
btoa(
|
||||
self.jukeboxDialog.data.sp_user +
|
||||
':' +
|
||||
self.jukeboxDialog.data.sp_secret
|
||||
)
|
||||
)
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', 'https://accounts.spotify.com/api/token', true)
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Basic ' +
|
||||
btoa(
|
||||
self.jukeboxDialog.data.sp_user +
|
||||
':' +
|
||||
self.jukeboxDialog.data.sp_secret
|
||||
)
|
||||
)
|
||||
xhr.send(body)
|
||||
xhr.onload = function () {
|
||||
let responseObj = JSON.parse(xhr.response)
|
||||
if (responseObj.access_token) {
|
||||
self.jukeboxDialog.data.sp_access_token = responseObj.access_token
|
||||
self.jukeboxDialog.data.sp_refresh_token = responseObj.refresh_token
|
||||
self.updateDB()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
console.log(this.g.user.wallets[0])
|
||||
var getJukeboxes = this.getJukeboxes
|
||||
getJukeboxes()
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.locationcbPath = String(
|
||||
[
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
'/jukebox/api/v1/jukebox/spotify/cb/'
|
||||
].join('')
|
||||
)
|
||||
this.locationcb = this.locationcbPath
|
||||
}
|
||||
})
|
19
lnbits/extensions/jukebox/static/js/jukebox.js
Normal file
19
lnbits/extensions/jukebox/static/js/jukebox.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
|
||||
},
|
||||
created() {
|
||||
|
||||
}
|
||||
})
|
BIN
lnbits/extensions/jukebox/static/spotapi.gif
Normal file
BIN
lnbits/extensions/jukebox/static/spotapi.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 215 KiB |
BIN
lnbits/extensions/jukebox/static/spotapi1.gif
Normal file
BIN
lnbits/extensions/jukebox/static/spotapi1.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 241 KiB |
101
lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
Normal file
101
lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
Normal file
|
@ -0,0 +1,101 @@
|
|||
<q-card-section>
|
||||
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
|
||||
<a style="color:#43a047" href="https://developer.spotify.com/dashboard/applications">here </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-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-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span>
|
||||
/jukebox/api/v1/jukebox</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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>[<jukebox_object>, ...]</code>
|
||||
<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: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get jukebox">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span>
|
||||
/jukebox/api/v1/jukebox/<juke_id></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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><jukebox_object></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -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="Create/update track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<code>{"X-Api-Key": <admin_key>}</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><jukbox_object></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X POST {{ request.url_root }}api/v1/jukebox/ -d
|
||||
'{"user": <string, user_id>,
|
||||
"title": <string>, "wallet":<string>, "sp_user":
|
||||
<string, spotify_user_account>, "sp_secret": <string, spotify_user_secret>, "sp_access_token":
|
||||
<string, not_required>, "sp_refresh_token":
|
||||
<string, not_required>, "sp_device": <string, spotify_user_secret>, "sp_playlists":
|
||||
<string, not_required>, "price":
|
||||
<integer, not_required>}' -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="Delete jukebox">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-red">DELETE</span>
|
||||
/jukebox/api/v1/jukebox/<juke_id></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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><jukebox_object></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
35
lnbits/extensions/jukebox/templates/jukebox/error.html
Normal file
35
lnbits/extensions/jukebox/templates/jukebox/error.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">Jukebox error</h3>
|
||||
<br />
|
||||
<q-icon
|
||||
name="warning"
|
||||
class="text-grey"
|
||||
style="font-size: 20rem"
|
||||
></q-icon>
|
||||
|
||||
<h5 class="q-my-none">Ask the host to turn on the device and launch spotify</h5>
|
||||
<br />
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
189
lnbits/extensions/jukebox/templates/jukebox/index.html
Normal file
189
lnbits/extensions/jukebox/templates/jukebox/index.html
Normal file
|
@ -0,0 +1,189 @@
|
|||
{% 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>
|
||||
<q-btn unelevated color="green-7" class="q-ma-lg" @click="openNewDialog()">Add Spotify Jukebox</q-btn>
|
||||
|
||||
{% raw %}
|
||||
|
||||
<q-table flat dense :data="JukeboxLinks" row-key="id" :columns="JukeboxTable.columns"
|
||||
:pagination.sync="JukeboxTable.pagination" :filter="filter">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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>
|
||||
<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="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.sp_id)">
|
||||
<q-tooltip> Jukebox QR </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn 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-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>
|
||||
</q-table>
|
||||
{% endraw %}
|
||||
</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 jukebox extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "jukebox/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<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-stepper v-model="step" active-color="green-7" inactive-color="green-10" vertical animated>
|
||||
<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>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<q-btn
|
||||
v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null"
|
||||
color="green-7" @click="step = 2">Continue</q-btn>
|
||||
<q-btn v-else color="green-7" disable>Continue</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
</q-step>
|
||||
|
||||
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
|
||||
<img src="/jukebox/static/spotapi.gif" />
|
||||
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
|
||||
<a 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 dense v-model="jukeboxDialog.data.sp_secret" filled :type="isPwd ? 'password' : 'text'"
|
||||
label="Client secret">
|
||||
<template #append>
|
||||
<q-icon :name="isPwd ? 'visibility_off' : 'visibility'" class="cursor-pointer" @click="isPwd = !isPwd">
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-4">
|
||||
<q-btn v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
|
||||
color="green-7" @click="submitSpotifyKeys">Submit keys</q-btn>
|
||||
<q-btn v-else color="green-7" disable color="green-7">Submit keys</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
</q-step>
|
||||
|
||||
<q-step :name="3" title="Add Redirect URI" icon="link" :done="step > 3">
|
||||
<img src="/jukebox/static/spotapi1.gif" />
|
||||
In the app go to edit-settings, set the redirect URI to this link
|
||||
<br />
|
||||
<q-btn dense outline unelevated color="green-7" 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-btn>
|
||||
<br />
|
||||
Settings can be found
|
||||
<a target="_blank" style="color:#43a047" href="https://developer.spotify.com/dashboard/applications">here</a>.
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-4">
|
||||
<q-btn v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
|
||||
color="green-7" @click="authAccess">Authorise access</q-btn>
|
||||
<q-btn v-else color="green-7" disable color="green-7">Authorise access</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
</q-step>
|
||||
|
||||
<q-step :name="4" title="Select playlists" icon="queue_music" active-color="green-8" :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="col-5">
|
||||
<q-btn v-if="jukeboxDialog.data.sp_device != null && jukeboxDialog.data.sp_playlists != null"
|
||||
color="green-7" @click="createJukebox">Create Jukebox</q-btn>
|
||||
<q-btn v-else color="green-7" disable>Create Jukebox</q-btn>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<q-btn color="green-7" class="float-right" @click="closeFormDialog">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
</q-stepper>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
<center>
|
||||
<h5 class="q-my-none">Shareable Jukebox QR</h5>
|
||||
</center>
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode :value="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id" :options="{width: 800}"
|
||||
class="rounded-borders"></qrcode>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn outline color="grey"
|
||||
@click="copyText(qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id, 'Link copied to clipboard!')">
|
||||
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>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||
<script src="/jukebox/static/js/index.js"></script>
|
||||
{% endblock %}
|
276
lnbits/extensions/jukebox/templates/jukebox/jukebox.html
Normal file
276
lnbits/extensions/jukebox/templates/jukebox/jukebox.html
Normal file
|
@ -0,0 +1,276 @@
|
|||
{% extends "public.html" %} {% block page %} {% raw %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<p style="font-size: 22px">Currently playing</p>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<img style="width: 100px" :src="currentPlay.image" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<strong style="font-size: 20px">{{ currentPlay.name }}</strong><br />
|
||||
<strong style="font-size: 15px">{{ currentPlay.artist }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<p style="font-size: 22px">Pick a song</p>
|
||||
<q-select outlined v-model="playlist" :options="playlists" label="playlists" @input="selectPlaylist()">
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-virtual-scroll style="max-height: 300px" :items="currentPlaylist" separator>
|
||||
<template v-slot="{ item, index }">
|
||||
<q-item :key="index" dense clickable v-ripple
|
||||
@click="payForSong(item.id, item.name, item.artist, item.image)">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ item.name }} - ({{ item.artist }})
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.dialogues.first" position="top">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<img style="width: 100px" :src="receive.image" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<strong style="font-size: 20px">{{ receive.name }}</strong><br />
|
||||
<strong style="font-size: 15px">{{ receive.artist }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<br />
|
||||
<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>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="receive.dialogues.second" position="top">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode :value="'lightning:' + receive.paymentReq" :options="{width: 800}" class="rounded-borders"></qrcode>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endraw %} {% endblock %} {% block scripts %}
|
||||
<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)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
currentPlaylist: [],
|
||||
currentlyPlaying: {},
|
||||
cancelListener: () => { },
|
||||
playlists: {},
|
||||
playlist: '',
|
||||
heavyList: [],
|
||||
selectedWallet: {},
|
||||
paid: false,
|
||||
receive: {
|
||||
dialogues: {
|
||||
first: false,
|
||||
second: false
|
||||
},
|
||||
paymentReq: '',
|
||||
paymentHash: '',
|
||||
name: '',
|
||||
artist: '',
|
||||
image: '',
|
||||
id: '',
|
||||
showQR: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentPlay() {
|
||||
return this.currentlyPlaying
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelPayment: function () {
|
||||
this.paymentReq = null
|
||||
clearInterval(this.paymentDialog.checker)
|
||||
if (this.paymentDialog.dismissMsg) {
|
||||
this.paymentDialog.dismissMsg()
|
||||
}
|
||||
},
|
||||
closeReceiveDialog() { },
|
||||
payForSong(song_id, name, artist, image) {
|
||||
self = this
|
||||
self.receive.name = name
|
||||
self.receive.artist = artist
|
||||
self.receive.image = image
|
||||
self.receive.id = song_id
|
||||
self.receive.dialogues.first = true
|
||||
},
|
||||
getInvoice(song_id) {
|
||||
self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/jukebox/api/v1/jukebox/jb/invoice/' +
|
||||
'{{ juke_id }}' +
|
||||
'/' +
|
||||
song_id
|
||||
)
|
||||
.then(function (response) {
|
||||
|
||||
self.receive.paymentReq = response.data[0][1]
|
||||
self.receive.paymentHash = response.data[0][0]
|
||||
self.receive.dialogues.second = true
|
||||
|
||||
var paymentChecker = setInterval(function () {
|
||||
if (!self.paid) {
|
||||
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 => {
|
||||
|
||||
self.$q.notify({
|
||||
color: 'warning',
|
||||
html: true,
|
||||
message:
|
||||
'<center>Device is not connected! <br/> Ask the host to turn on their device and have Spotify open</center>',
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
},
|
||||
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() {
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/jukebox/api/v1/jukebox/jb/currently/{{juke_id}}')
|
||||
.then(function (res) {
|
||||
if (res.data.id) {
|
||||
|
||||
self.currentlyPlaying = res.data
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
|
||||
},
|
||||
selectPlaylist() {
|
||||
self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/jukebox/api/v1/jukebox/jb/playlist/' +
|
||||
'{{ juke_id }}' +
|
||||
'/' +
|
||||
self.playlist.split(',')[0].split('-')[1]
|
||||
)
|
||||
.then(function (response) {
|
||||
self.currentPlaylist = response.data
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
currentSong() { }
|
||||
},
|
||||
created() {
|
||||
this.getCurrent()
|
||||
this.playlists = JSON.parse('{{ playlists | tojson }}')
|
||||
|
||||
self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/jukebox/api/v1/jukebox/jb/playlist/' +
|
||||
'{{ juke_id }}' +
|
||||
'/' +
|
||||
self.playlists[0].split(',')[0].split('-')[1]
|
||||
)
|
||||
.then(function (response) {
|
||||
self.currentPlaylist = response.data
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
|
||||
// this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
42
lnbits/extensions/jukebox/views.py
Normal file
42
lnbits/extensions/jukebox/views.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import time
|
||||
from datetime import datetime
|
||||
from quart import g, render_template, request, jsonify, websocket
|
||||
from http import HTTPStatus
|
||||
import trio
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
|
||||
import json
|
||||
from . import jukebox_ext
|
||||
from .crud import get_jukebox
|
||||
from .views_api import api_get_jukebox_device_check
|
||||
|
||||
|
||||
@jukebox_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("jukebox/index.html", user=g.user)
|
||||
|
||||
|
||||
@jukebox_ext.route("/<juke_id>")
|
||||
async def connect_to_jukebox(juke_id):
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
return "error"
|
||||
deviceCheck = await api_get_jukebox_device_check(juke_id)
|
||||
devices = json.loads(deviceCheck[0].text)
|
||||
deviceConnected = False
|
||||
for device in devices["devices"]:
|
||||
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||
deviceConnected = True
|
||||
if deviceConnected:
|
||||
return await render_template(
|
||||
"jukebox/jukebox.html",
|
||||
playlists=jukebox.sp_playlists.split(","),
|
||||
juke_id=juke_id,
|
||||
price=jukebox.price,
|
||||
inkey=jukebox.inkey,
|
||||
)
|
||||
else:
|
||||
return await render_template("jukebox/error.html")
|
490
lnbits/extensions/jukebox/views_api.py
Normal file
490
lnbits/extensions/jukebox/views_api.py
Normal file
|
@ -0,0 +1,490 @@
|
|||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
import base64
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
import json
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
import httpx
|
||||
from . import jukebox_ext
|
||||
from .crud import (
|
||||
create_jukebox,
|
||||
update_jukebox,
|
||||
get_jukebox,
|
||||
get_jukeboxs,
|
||||
delete_jukebox,
|
||||
create_jukebox_payment,
|
||||
get_jukebox_payment,
|
||||
update_jukebox_payment,
|
||||
)
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_get_jukeboxs():
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
[{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except:
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
##################SPOTIFY AUTH#####################
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/spotify/cb/<juke_id>", methods=["GET"])
|
||||
async def api_check_credentials_callbac(juke_id):
|
||||
sp_code = ""
|
||||
sp_access_token = ""
|
||||
sp_refresh_token = ""
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
if request.args.get("code"):
|
||||
sp_code = request.args.get("code")
|
||||
jukebox = await update_jukebox(
|
||||
juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code
|
||||
)
|
||||
if request.args.get("access_token"):
|
||||
sp_access_token = request.args.get("access_token")
|
||||
sp_refresh_token = request.args.get("refresh_token")
|
||||
jukebox = await update_jukebox(
|
||||
juke_id=juke_id,
|
||||
sp_secret=jukebox.sp_secret,
|
||||
sp_access_token=sp_access_token,
|
||||
sp_refresh_token=sp_refresh_token,
|
||||
)
|
||||
return "<h1>Success!</h1><h2>You can close this window</h2>"
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_check_credentials_check(juke_id):
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"])
|
||||
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"user": {"type": "string", "empty": False, "required": True},
|
||||
"title": {"type": "string", "empty": False, "required": True},
|
||||
"wallet": {"type": "string", "empty": False, "required": True},
|
||||
"sp_user": {"type": "string", "empty": False, "required": True},
|
||||
"sp_secret": {"type": "string", "required": True},
|
||||
"sp_access_token": {"type": "string", "required": False},
|
||||
"sp_refresh_token": {"type": "string", "required": False},
|
||||
"sp_device": {"type": "string", "required": False},
|
||||
"sp_playlists": {"type": "string", "required": False},
|
||||
"price": {"type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
async def api_create_update_jukebox(juke_id=None):
|
||||
if juke_id:
|
||||
jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data)
|
||||
else:
|
||||
jukebox = await create_jukebox(inkey=g.wallet.inkey, **g.data)
|
||||
|
||||
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_delete_item(juke_id):
|
||||
await delete_jukebox(juke_id)
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
[{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except:
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
################JUKEBOX ENDPOINTS##################
|
||||
|
||||
######GET ACCESS TOKEN######
|
||||
|
||||
|
||||
@jukebox_ext.route(
|
||||
"/api/v1/jukebox/jb/playlist/<juke_id>/<sp_playlist>", methods=["GET"]
|
||||
)
|
||||
async def api_get_jukebox_song(juke_id, sp_playlist, retry=False):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
tracks = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
"https://api.spotify.com/v1/playlists/" + sp_playlist + "/tracks",
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
if "items" not in r.json():
|
||||
if r.status_code == 401:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return False
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return await api_get_jukebox_song(
|
||||
juke_id, sp_playlist, retry=True
|
||||
)
|
||||
return r, HTTPStatus.OK
|
||||
for item in r.json()["items"]:
|
||||
tracks.append(
|
||||
{
|
||||
"id": item["track"]["id"],
|
||||
"name": item["track"]["name"],
|
||||
"album": item["track"]["album"]["name"],
|
||||
"artist": item["track"]["artists"][0]["name"],
|
||||
"image": item["track"]["album"]["images"][0]["url"],
|
||||
}
|
||||
)
|
||||
except AssertionError:
|
||||
something = None
|
||||
return jsonify([track for track in tracks])
|
||||
|
||||
|
||||
async def api_get_token(juke_id):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
timeout=40,
|
||||
params={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": jukebox.sp_refresh_token,
|
||||
"client_id": jukebox.sp_user,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": "Basic "
|
||||
+ base64.b64encode(
|
||||
str(jukebox.sp_user + ":" + jukebox.sp_secret).encode("ascii")
|
||||
).decode("ascii"),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
if "access_token" not in r.json():
|
||||
return False
|
||||
else:
|
||||
await update_jukebox(
|
||||
juke_id=juke_id, sp_access_token=r.json()["access_token"]
|
||||
)
|
||||
except AssertionError:
|
||||
something = None
|
||||
return True
|
||||
|
||||
|
||||
######CHECK DEVICE
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/jb/<juke_id>", methods=["GET"])
|
||||
async def api_get_jukebox_device_check(juke_id, retry=False):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
rDevice = await client.get(
|
||||
"https://api.spotify.com/v1/me/player/devices",
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
|
||||
if rDevice.status_code == 204 or rDevice.status_code == 200:
|
||||
return (
|
||||
rDevice,
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif rDevice.status_code == 401 or rDevice.status_code == 403:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return (
|
||||
jsonify({"error": "No device connected"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return api_get_jukebox_device_check(juke_id, retry=True)
|
||||
else:
|
||||
return (
|
||||
jsonify({"error": "No device connected"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
######GET INVOICE STUFF
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/jb/invoice/<juke_id>/<song_id>", methods=["GET"])
|
||||
async def api_get_jukebox_invoice(juke_id, song_id):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
deviceCheck = await api_get_jukebox_device_check(juke_id)
|
||||
devices = json.loads(deviceCheck[0].text)
|
||||
deviceConnected = False
|
||||
for device in devices["devices"]:
|
||||
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||
deviceConnected = True
|
||||
if not deviceConnected:
|
||||
return (
|
||||
jsonify({"error": "No device connected"}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No device connected"}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
invoice = await create_invoice(
|
||||
wallet_id=jukebox.wallet,
|
||||
amount=jukebox.price,
|
||||
memo=jukebox.title,
|
||||
extra={"tag": "jukebox"},
|
||||
)
|
||||
|
||||
jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id)
|
||||
|
||||
return jsonify(invoice, jukebox_payment)
|
||||
|
||||
|
||||
@jukebox_ext.route(
|
||||
"/api/v1/jukebox/jb/checkinvoice/<pay_hash>/<juke_id>", methods=["GET"]
|
||||
)
|
||||
async def api_get_jukebox_invoice_check(pay_hash, juke_id):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
status = await check_invoice_status(jukebox.wallet, pay_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception as exc:
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
if is_paid:
|
||||
wallet = await get_wallet(jukebox.wallet)
|
||||
payment = await wallet.get_payment(pay_hash)
|
||||
await payment.set_pending(False)
|
||||
await update_jukebox_payment(pay_hash, paid=True)
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
|
||||
@jukebox_ext.route(
|
||||
"/api/v1/jukebox/jb/invoicep/<song_id>/<juke_id>/<pay_hash>", methods=["GET"]
|
||||
)
|
||||
async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
jukebox_payment = await get_jukebox_payment(pay_hash)
|
||||
if jukebox_payment.paid:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
rDevice = await client.get(
|
||||
"https://api.spotify.com/v1/me/player",
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
isPlaying = False
|
||||
if rDevice.status_code == 200:
|
||||
isPlaying = rDevice.json()["is_playing"]
|
||||
|
||||
if r.status_code == 204 or isPlaying == False:
|
||||
async with httpx.AsyncClient() as client:
|
||||
uri = ["spotify:track:" + song_id]
|
||||
r = await client.put(
|
||||
"https://api.spotify.com/v1/me/player/play?device_id="
|
||||
+ jukebox.sp_device.split("-")[1],
|
||||
json={"uris": uri},
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
if r.status_code == 204:
|
||||
return jsonify(jukebox_payment), HTTPStatus.OK
|
||||
elif r.status_code == 401 or r.status_code == 403:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return api_get_jukebox_invoice_paid(
|
||||
song_id, juke_id, pay_hash, retry=True
|
||||
)
|
||||
else:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
elif r.status_code == 200:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
"https://api.spotify.com/v1/me/player/queue?uri=spotify%3Atrack%3A"
|
||||
+ song_id
|
||||
+ "&device_id="
|
||||
+ jukebox.sp_device.split("-")[1],
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
if r.status_code == 204:
|
||||
return jsonify(jukebox_payment), HTTPStatus.OK
|
||||
|
||||
elif r.status_code == 401 or r.status_code == 403:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return await api_get_jukebox_invoice_paid(
|
||||
song_id, juke_id, pay_hash
|
||||
)
|
||||
else:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif r.status_code == 401 or r.status_code == 403:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return await api_get_jukebox_invoice_paid(
|
||||
song_id, juke_id, pay_hash
|
||||
)
|
||||
return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK
|
||||
|
||||
|
||||
############################GET TRACKS
|
||||
|
||||
|
||||
@jukebox_ext.route("/api/v1/jukebox/jb/currently/<juke_id>", methods=["GET"])
|
||||
async def api_get_jukebox_currently(juke_id, retry=False):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
return (
|
||||
jsonify({"error": "No Jukebox"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
||||
timeout=40,
|
||||
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||
)
|
||||
if r.status_code == 204:
|
||||
return jsonify({"error": "Nothing"}), HTTPStatus.OK
|
||||
elif r.status_code == 200:
|
||||
try:
|
||||
response = r.json()
|
||||
|
||||
track = {
|
||||
"id": response["item"]["id"],
|
||||
"name": response["item"]["name"],
|
||||
"album": response["item"]["album"]["name"],
|
||||
"artist": response["item"]["artists"][0]["name"],
|
||||
"image": response["item"]["album"]["images"][0]["url"],
|
||||
}
|
||||
return jsonify(track), HTTPStatus.OK
|
||||
except:
|
||||
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
|
||||
|
||||
elif r.status_code == 401:
|
||||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
return (
|
||||
jsonify({"error": "Invoice not paid"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
elif retry:
|
||||
return (
|
||||
jsonify({"error": "Failed to get auth"}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return await api_get_jukebox_currently(juke_id, retry=True)
|
||||
else:
|
||||
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
|
||||
except AssertionError:
|
||||
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
|
|
@ -1,13 +1,45 @@
|
|||
# DJ Livestream
|
||||
|
||||
An extension to help DJs to conduct music livestreams.
|
||||
## Help DJ's and music producers conduct music livestreams
|
||||
|
||||
It produces a single static QR code that can be shown on screen. Once someone scans that QR code with an lnurl-pay capable wallet they will see the name of the track being played at that time and the name of the producer.
|
||||
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
|
||||
They will then be given the opportunity to send a tip and a message related to that specific track and if they pay an amount over a specific threshold they will be given a link to download it (optional).
|
||||
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
[](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop')
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start by adding a track\
|
||||

|
||||
- set the producer, or choose an existing one
|
||||
- set the track name
|
||||
- define a minimum price where a user can download the track
|
||||
- set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\
|
||||

|
||||
2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\
|
||||

|
||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||

|
||||
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
5. After all tracks and producers are added, you can start "playing" songs\
|
||||

|
||||
6. You'll see the current track playing and a green icon indicating active track also\
|
||||

|
||||
7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats
|
||||
- producer's wallet receiving 18 sats from 20 sats tips\
|
||||

|
||||
|
||||
## Use cases
|
||||
|
||||
You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast.
|
||||
|
||||
You can use the extension's API to trigger updates for the current track, update fees, add tracks...
|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
||||
|
|
|
@ -61,7 +61,7 @@ async def lnurl_callback(track_id):
|
|||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
amount_received = int(request.args.get("amount") or 0)
|
||||
|
||||
if amount_received < track.min_sendable:
|
||||
return (
|
||||
|
|
|
@ -37,6 +37,7 @@ new Vue({
|
|||
},
|
||||
methods: {
|
||||
getTrackLabel(trackId) {
|
||||
if (!trackId) return
|
||||
let track = this.tracksMap[trackId]
|
||||
return `${track.name}, ${this.producersMap[track.producer].name}`
|
||||
},
|
||||
|
@ -162,6 +163,7 @@ new Vue({
|
|||
})
|
||||
},
|
||||
updateCurrentTrack(track) {
|
||||
console.log(this.nextCurrentTrack, this.livestream)
|
||||
if (this.livestream.current_track === track) {
|
||||
// if clicking the same, stop it
|
||||
track = 0
|
||||
|
@ -175,6 +177,7 @@ new Vue({
|
|||
)
|
||||
.then(() => {
|
||||
this.livestream.current_track = track
|
||||
this.nextCurrentTrack = track
|
||||
this.$q.notify({
|
||||
message: `Current track updated.`,
|
||||
timeout: 700
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import json
|
||||
import trio # type: ignore
|
||||
import trio
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import create_payment
|
||||
|
|
|
@ -27,8 +27,8 @@
|
|||
<div class="col">
|
||||
{% raw %}
|
||||
<q-btn unelevated color="deep-purple" type="submit">
|
||||
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
|
||||
}} current track
|
||||
{{ nextCurrentTrack && nextCurrentTrack ===
|
||||
livestream.current_track ? 'Stop' : 'Set' }} current track
|
||||
</q-btn>
|
||||
{% endraw %}
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,29 @@
|
|||
<h1>Support Tickets</h1>
|
||||
<h2>Get paid sats to answer questions</h2>
|
||||
Charge people per word for contacting you. Possible applications include, paid support ticketing, PAYG language services, contact spam protection.
|
||||
# Support Tickets
|
||||
|
||||
## Get paid sats to answer questions
|
||||
|
||||
Charge a per word amount for people to contact you.
|
||||
|
||||
Possible applications include, paid support ticketing, PAYG language services, contact spam protection.
|
||||
|
||||
1. Click "NEW FORM" to create a new contact form\
|
||||

|
||||
2. Fill out the contact form
|
||||
- set the wallet to use
|
||||
- give your form a name
|
||||
- set an optional webhook that will get called when the form receives a payment
|
||||
- give it a small description
|
||||
- set the amount you want to charge, per **word**, for people to contact you\
|
||||

|
||||
3. Your new contact form will appear on the _Forms_ section. Note that you can create various forms with different rates per word, for different purposes\
|
||||

|
||||
4. When a user wants to reach out to you, they will get to the contact form. They can fill out some information:
|
||||
- a name
|
||||
- an optional email if they want you to reply
|
||||
- and the actual message
|
||||
- at the bottom, a value in satoshis, will display how much it will cost them to send this message\
|
||||

|
||||
- after submiting the Lightning Network invoice will pop up and after payment the message will be sent to you\
|
||||

|
||||
5. Back in "Support ticket" extension you'll get the messages your fans, users, haters, etc, sent you on the _Tickets_ section\
|
||||

|
||||
|
|
|
@ -10,3 +10,8 @@ lnticket_ext: Blueprint = Blueprint(
|
|||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
lnticket_ext.record(record_async(register_listeners))
|
||||
|
|
36
lnbits/extensions/lnticket/tasks.py
Normal file
36
lnbits/extensions/lnticket/tasks.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
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)
|
|
@ -77,7 +77,7 @@
|
|||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
console.log('{{ form_costpword }}')
|
||||
//console.log('{{ form_costpword }}')
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
|
@ -99,7 +99,11 @@
|
|||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null
|
||||
}
|
||||
},
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
cancelListener: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -128,12 +132,35 @@
|
|||
},
|
||||
|
||||
closeReceiveDialog: function () {
|
||||
var checker = this.receive.paymentChecker
|
||||
var checker = this.startPaymentNotifier
|
||||
dismissMsg()
|
||||
|
||||
clearInterval(paymentChecker)
|
||||
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 () {
|
||||
var self = this
|
||||
axios
|
||||
|
@ -158,39 +185,15 @@
|
|||
status: 'pending',
|
||||
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) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.wallet.inkey = '{{form_wallet}}'
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -90,6 +90,16 @@
|
|||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
|
||||
</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">
|
||||
<q-btn flat color="grey" @click="exportticketsCSV"
|
||||
>Export to CSV</q-btn
|
||||
|
@ -230,7 +240,7 @@
|
|||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapLNTicket = function (obj) {
|
||||
const mapLNTicket = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
|
@ -290,7 +300,8 @@
|
|||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
cancelListener: () => {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -304,9 +315,12 @@
|
|||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
return mapLNTicket(obj)
|
||||
})
|
||||
self.tickets = response.data
|
||||
.map(function (obj) {
|
||||
if (!obj?.paid) return
|
||||
return mapLNTicket(obj)
|
||||
})
|
||||
.filter(v => v)
|
||||
})
|
||||
},
|
||||
deleteTicket: function (ticketId) {
|
||||
|
@ -355,6 +369,7 @@
|
|||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
this.formDialog.data.inkey = wallet.inkey
|
||||
var data = this.formDialog.data
|
||||
|
||||
if (data.id) {
|
||||
|
@ -366,6 +381,7 @@
|
|||
|
||||
createForm: function (wallet, data) {
|
||||
var self = this
|
||||
console.log('create', data)
|
||||
LNbits.api
|
||||
.request('POST', '/lnticket/api/v1/forms', wallet.inkey, data)
|
||||
.then(function (response) {
|
||||
|
@ -379,7 +395,7 @@
|
|||
},
|
||||
updateformDialog: function (formId) {
|
||||
var link = _.findWhere(this.forms, {id: formId})
|
||||
console.log(link.id)
|
||||
|
||||
this.formDialog.data.id = link.id
|
||||
this.formDialog.data.wallet = link.wallet
|
||||
this.formDialog.data.name = link.name
|
||||
|
@ -389,8 +405,7 @@
|
|||
},
|
||||
updateForm: function (wallet, data) {
|
||||
var self = this
|
||||
console.log(data)
|
||||
|
||||
console.log('update', data)
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
|
@ -435,6 +450,22 @@
|
|||
},
|
||||
exportformsCSV: function () {
|
||||
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'
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -442,6 +473,7 @@
|
|||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getForms()
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from quart import g, abort, render_template
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from http import HTTPStatus
|
||||
|
||||
|
@ -20,10 +21,13 @@ async def display(form_id):
|
|||
if not form:
|
||||
abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
|
||||
|
||||
wallet = await get_wallet(form.wallet)
|
||||
|
||||
return await render_template(
|
||||
"lnticket/display.html",
|
||||
form_id=form.id,
|
||||
form_name=form.name,
|
||||
form_desc=form.description,
|
||||
form_costpword=form.costpword,
|
||||
form_wallet=wallet.inkey
|
||||
)
|
||||
|
|
|
@ -1 +1,27 @@
|
|||
# LNURLp
|
||||
|
||||
## Create a static QR code people can use to pay over Lightning Network
|
||||
|
||||
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an LNURLp (New Pay link)\
|
||||

|
||||
|
||||
- select your wallets
|
||||
- make a small description
|
||||
- enter amount
|
||||
- if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount
|
||||
- you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp
|
||||
- You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post)
|
||||
- Webhook URL allows to call an URL when the LNURLp is paid
|
||||
- Success mesage, will send a message back to the user after a successful payment, for example a thank you note
|
||||
- Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link
|
||||
|
||||
2. Use the shareable link or view the LNURLp you just created\
|
||||

|
||||
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
|
||||

|
||||
|
|
|
@ -54,7 +54,7 @@ async def api_lnurl_callback(link_id):
|
|||
min = link.min * 1000
|
||||
max = link.max * 1000
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
amount_received = int(request.args.get("amount") or 0)
|
||||
if amount_received < min:
|
||||
return (
|
||||
jsonify(
|
||||
|
@ -95,10 +95,17 @@ async def api_lnurl_callback(link_id):
|
|||
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
|
||||
)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=link.success_action(payment_hash),
|
||||
routes=[],
|
||||
)
|
||||
success_action = link.success_action(payment_hash)
|
||||
if success_action:
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=success_action,
|
||||
routes=[],
|
||||
)
|
||||
else:
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
|
|
|
@ -5,7 +5,6 @@ from typing import NamedTuple, Optional, Dict
|
|||
from sqlite3 import Row
|
||||
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction # type: ignore
|
||||
|
||||
|
||||
class PayLink(NamedTuple):
|
||||
|
@ -36,15 +35,21 @@ class PayLink(NamedTuple):
|
|||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
def success_action(self, payment_hash: str) -> Optional[Dict]:
|
||||
if self.success_url:
|
||||
url: ParseResult = urlparse(self.success_url)
|
||||
qs: Dict = parse_qs(url.query)
|
||||
qs["payment_hash"] = payment_hash
|
||||
url = url._replace(query=urlencode(qs, doseq=True))
|
||||
raw: str = urlunparse(url)
|
||||
return UrlAction(url=raw, description=self.success_text)
|
||||
return {
|
||||
"tag": "url",
|
||||
"description": self.success_text or "~",
|
||||
"url": urlunparse(url),
|
||||
}
|
||||
elif self.success_text:
|
||||
return MessageAction(message=self.success_text)
|
||||
return {
|
||||
"tag": "message",
|
||||
"message": self.success_text,
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
|
|
@ -62,7 +62,9 @@ new Vue({
|
|||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {},
|
||||
closeFormDialog() {
|
||||
this.resetFormData()
|
||||
},
|
||||
openQrCodeDialog(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
@ -116,6 +118,13 @@ new Vue({
|
|||
this.createPayLink(wallet, data)
|
||||
}
|
||||
},
|
||||
resetFormData() {
|
||||
this.formDialog = {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
updatePayLink(wallet, data) {
|
||||
let values = _.omit(
|
||||
_.pick(
|
||||
|
@ -147,6 +156,7 @@ new Vue({
|
|||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
|
@ -158,12 +168,13 @@ new Vue({
|
|||
.then(response => {
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deletePayLink: linkId => {
|
||||
deletePayLink(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
|
@ -204,7 +215,6 @@ new Vue({
|
|||
getPayLinks()
|
||||
}, 20000)
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/lnurlp/api/v1/currencies')
|
||||
.then(response => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import httpx
|
||||
|
||||
|
|
|
@ -88,6 +88,12 @@ async def api_link_create_or_update(link_id=None):
|
|||
):
|
||||
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
if "success_url" in g.data and g.data["success_url"][:8] != "https://":
|
||||
return (
|
||||
jsonify({"message": "Success URL must be secure https://..."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if link_id:
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
|
|
|
@ -1 +1,36 @@
|
|||
# Offline Shop
|
||||
|
||||
## Create QR codes for each product and display them on your store for receiving payments Offline
|
||||
|
||||
[](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
|
||||
|
||||
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
|
||||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful.
|
||||
|
||||
Costumers must use an LNURL pay capable wallet.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\
|
||||

|
||||
2. Begin by creating an item, click "ADD NEW ITEM"
|
||||
- set the item name and a small description
|
||||
- you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_
|
||||
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\
|
||||

|
||||
3. After creating some products, click on "PRINT QR CODES"\
|
||||

|
||||
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
|
||||

|
||||
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
|
||||
6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\
|
||||

|
||||
|
||||
- Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
|
||||

|
||||
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
|
||||

|
||||
- Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES"
|
||||
|
|
|
@ -49,7 +49,7 @@ async def lnurl_callback(item_id):
|
|||
min = price * 995
|
||||
max = price * 1010
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
amount_received = int(request.args.get("amount") or 0)
|
||||
if amount_received < min:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
|
|
|
@ -291,7 +291,8 @@
|
|||
dense
|
||||
v-model.number="itemDialog.data.price"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
:label="`Item price (${itemDialog.data.unit})`"
|
||||
></q-input>
|
||||
<q-select
|
||||
|
|
|
@ -1,11 +1,22 @@
|
|||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
# Paywall
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
## Hide content behind a paywall, a user has to pay some amount to access your hidden content
|
||||
|
||||
A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc...
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
## Usage
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
||||
1. Create a paywall by clicking "NEW PAYWALL"\
|
||||

|
||||
2. Fill the options for your PAYWALL
|
||||
- select the wallet
|
||||
- set the link that will be unlocked after a successful payment
|
||||
- give your paywall a _Title_
|
||||
- an optional small description
|
||||
- and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish
|
||||
- if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\
|
||||

|
||||
3. You can then use your paywall link to secure your awesome content\
|
||||

|
||||
4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\
|
||||

|
||||
|
|
|
@ -1,4 +1,27 @@
|
|||
# SatsPay Server
|
||||
|
||||
Create onchain and LN charges. Includes webhooks!
|
||||
## Create onchain and LN charges. Includes webhooks!
|
||||
|
||||
Easilly create invoices that support Lightning Network and on-chain BTC payment.
|
||||
|
||||
1. Create a "NEW CHARGE"\
|
||||

|
||||
2. Fill out the invoice fields
|
||||
- set a descprition for the payment
|
||||
- the amount in sats
|
||||
- the time, in minutes, the invoice is valid for, after this period the invoice can't be payed
|
||||
- set a webhook that will get the transaction details after a successful payment
|
||||
- set to where the user should redirect after payment
|
||||
- set the text for the button that will show after payment (not setting this, will display "NONE" in the button)
|
||||
- select if you want onchain payment, LN payment or both
|
||||
- depending on what you select you'll have to choose the respective wallets where to receive your payment\
|
||||

|
||||
3. The charge will appear on the _Charges_ section\
|
||||

|
||||
4. Your costumer/payee will get the payment page
|
||||
- they can choose to pay on LN\
|
||||

|
||||
- or pay on chain\
|
||||

|
||||
5. You can check the state of your charges in LNBits\
|
||||

|
||||
|
|
|
@ -22,7 +22,7 @@ async def create_charge(
|
|||
lnbitswallet: Optional[str] = None,
|
||||
webhook: Optional[str] = None,
|
||||
completelink: Optional[str] = None,
|
||||
completelinktext: Optional[str] = None,
|
||||
completelinktext: Optional[str] = "Back to Merchant",
|
||||
time: Optional[int] = None,
|
||||
amount: Optional[int] = None,
|
||||
) -> Charges:
|
||||
|
|
|
@ -169,7 +169,7 @@
|
|||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
v-if="'{{ charge.webhook }}' != None"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
|
|
34
lnbits/extensions/splitpayments/README.md
Normal file
34
lnbits/extensions/splitpayments/README.md
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Split Payments
|
||||
|
||||
## Have payments split between multiple wallets
|
||||
|
||||
LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
|
||||
|
||||
## Usage
|
||||
|
||||
1. After enabling the extension, choose the source wallet that will receive and distribute the Payments
|
||||
|
||||

|
||||
|
||||
2. Add the wallet or wallets info to split payments to
|
||||
|
||||
 - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\
|
||||
 - set a wallet _Alias_ for your own identification\
|
||||
|
||||
- set how much, in percentage, this wallet will receive from every payment sent to the source wallets
|
||||
|
||||
3. When done, click "SAVE TARGETS" to make the splits effective
|
||||
|
||||
4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100%
|
||||
|
||||
5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\
|
||||
- on receiving a 20 sats payment\
|
||||

|
||||
- source wallet gets 18 sats\
|
||||

|
||||
- Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\
|
||||

|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
18
lnbits/extensions/splitpayments/__init__.py
Normal file
18
lnbits/extensions/splitpayments/__init__.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_splitpayments")
|
||||
|
||||
splitpayments_ext: Blueprint = Blueprint(
|
||||
"splitpayments", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
splitpayments_ext.record(record_async(register_listeners))
|
9
lnbits/extensions/splitpayments/config.json
Normal file
9
lnbits/extensions/splitpayments/config.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "SplitPayments",
|
||||
"short_description": "Split incoming payments to other wallets.",
|
||||
"icon": "call_split",
|
||||
"contributors": [
|
||||
"fiatjaf",
|
||||
"cryptograffiti"
|
||||
]
|
||||
}
|
23
lnbits/extensions/splitpayments/crud.py
Normal file
23
lnbits/extensions/splitpayments/crud.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from typing import List
|
||||
|
||||
from . import db
|
||||
from .models import Target
|
||||
|
||||
|
||||
async def get_targets(source_wallet: str) -> List[Target]:
|
||||
rows = await db.fetchall("SELECT * FROM targets WHERE source = ?", (source_wallet,))
|
||||
return [Target(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def set_targets(source_wallet: str, targets: List[Target]):
|
||||
async with db.connect() as conn:
|
||||
await conn.execute("DELETE FROM targets WHERE source = ?", (source_wallet,))
|
||||
for target in targets:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO targets
|
||||
(source, wallet, percent, alias)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(source_wallet, target.wallet, target.percent, target.alias),
|
||||
)
|
16
lnbits/extensions/splitpayments/migrations.py
Normal file
16
lnbits/extensions/splitpayments/migrations.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial split payment table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE targets (
|
||||
wallet TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
||||
alias TEXT,
|
||||
|
||||
UNIQUE (source, wallet)
|
||||
);
|
||||
"""
|
||||
)
|
8
lnbits/extensions/splitpayments/models.py
Normal file
8
lnbits/extensions/splitpayments/models.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Target(NamedTuple):
|
||||
wallet: str
|
||||
source: str
|
||||
percent: int
|
||||
alias: str
|
143
lnbits/extensions/splitpayments/static/js/index.js
Normal file
143
lnbits/extensions/splitpayments/static/js/index.js
Normal file
|
@ -0,0 +1,143 @@
|
|||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
function hashTargets(targets) {
|
||||
return targets
|
||||
.filter(isTargetComplete)
|
||||
.map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function isTargetComplete(target) {
|
||||
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
selectedWallet: null,
|
||||
currentHash: '', // a string that must match if the edit data is unchanged
|
||||
targets: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isDirty() {
|
||||
return hashTargets(this.targets) !== this.currentHash
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearTargets() {
|
||||
this.targets = [{}]
|
||||
this.$q.notify({
|
||||
message:
|
||||
'Cleared the form, but not saved. You must click to save manually.',
|
||||
timeout: 500
|
||||
})
|
||||
},
|
||||
getTargets() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/splitpayments/api/v1/targets',
|
||||
this.selectedWallet.adminkey
|
||||
)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
this.currentHash = hashTargets(response.data)
|
||||
this.targets = response.data.concat({})
|
||||
})
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.getTargets()
|
||||
},
|
||||
targetChanged(isPercent, index) {
|
||||
// fix percent min and max range
|
||||
if (isPercent) {
|
||||
if (this.targets[index].percent > 100) this.targets[index].percent = 100
|
||||
if (this.targets[index].percent < 0) this.targets[index].percent = 0
|
||||
}
|
||||
|
||||
// remove empty lines (except last)
|
||||
if (this.targets.length >= 2) {
|
||||
for (let i = this.targets.length - 2; i >= 0; i--) {
|
||||
let target = this.targets[i]
|
||||
if (
|
||||
(!target.wallet || target.wallet.trim() === '') &&
|
||||
(!target.alias || target.alias.trim() === '') &&
|
||||
!target.percent
|
||||
) {
|
||||
this.targets.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add a line at the end if the last one is filled
|
||||
let last = this.targets[this.targets.length - 1]
|
||||
if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) {
|
||||
this.targets.push({})
|
||||
}
|
||||
|
||||
// sum of all percents
|
||||
let currentTotal = this.targets.reduce(
|
||||
(acc, target) => acc + (target.percent || 0),
|
||||
0
|
||||
)
|
||||
|
||||
// remove last (unfilled) line if the percent is already 100
|
||||
if (currentTotal >= 100) {
|
||||
let last = this.targets[this.targets.length - 1]
|
||||
if (
|
||||
(!last.wallet || last.wallet.trim() === '') &&
|
||||
(!last.alias || last.alias.trim() === '') &&
|
||||
!last.percent
|
||||
) {
|
||||
this.targets = this.targets.slice(0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// adjust percents of other lines (not this one)
|
||||
if (currentTotal > 100 && isPercent) {
|
||||
let diff = (currentTotal - 100) / (100 - this.targets[index].percent)
|
||||
this.targets.forEach((target, t) => {
|
||||
if (t !== index) target.percent -= Math.round(diff * target.percent)
|
||||
})
|
||||
}
|
||||
|
||||
// overwrite so changes appear
|
||||
this.targets = this.targets
|
||||
},
|
||||
saveTargets() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/splitpayments/api/v1/targets',
|
||||
this.selectedWallet.adminkey,
|
||||
{
|
||||
targets: this.targets
|
||||
.filter(isTargetComplete)
|
||||
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: 'Split payments targets set.',
|
||||
timeout: 700
|
||||
})
|
||||
this.getTargets()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.getTargets()
|
||||
}
|
||||
})
|
77
lnbits/extensions/splitpayments/tasks.py
Normal file
77
lnbits/extensions/splitpayments/tasks.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
import json
|
||||
import trio
|
||||
|
||||
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_targets
|
||||
|
||||
|
||||
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 "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"):
|
||||
# already splitted, ignore
|
||||
return
|
||||
|
||||
# now we make some special internal transfers (from no one to the receiver)
|
||||
targets = await get_targets(payment.wallet_id)
|
||||
transfers = [
|
||||
(target.wallet, int(target.percent * payment.amount / 100))
|
||||
for target in targets
|
||||
]
|
||||
transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0]
|
||||
amount_left = payment.amount - sum([amount for _, amount in transfers])
|
||||
|
||||
if amount_left < 0:
|
||||
print("splitpayments failure: amount_left is negative.", payment.payment_hash)
|
||||
return
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
# mark the original payment with one extra key, "splitted"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(dict(**payment.extra, splitted=True)),
|
||||
amount_left,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform the internal transfer using the same payment_hash
|
||||
for wallet, amount in transfers:
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
memo=payment.memo,
|
||||
pending=False,
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
|
@ -0,0 +1,90 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Add some wallets to the list of "Target Wallets", each with an
|
||||
associated <em>percent</em>. After saving, every time any payment
|
||||
arrives at the "Source Wallet" that payment will be split with the
|
||||
target wallets according to their percent.
|
||||
</p>
|
||||
<p>This is valid for every payment, doesn't matter how it was created.</p>
|
||||
<p>Target wallets can be any wallet from this same LNbits instance.</p>
|
||||
<p>
|
||||
To remove a wallet from the targets list, just erase its fields and
|
||||
save. To remove all, click "Clear" then save.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<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 Target Wallets"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/splitpayments/api/v1/targets</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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
|
||||
>[{"wallet": <wallet id>, "alias": <chosen name for this
|
||||
wallet>, "percent": <number between 1 and 100>}, ...]</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/livestream -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="Set Target Wallets"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/splitpayments/api/v1/targets</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type:
|
||||
application/json' -d '{"targets": [{"wallet": <wallet id or invoice
|
||||
key>, "alias": <name to identify this>, "percent": <number
|
||||
between 1 and 100>}, ...]}'
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
|
@ -0,0 +1,98 @@
|
|||
{% 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 class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Source Wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Target Wallets</h5>
|
||||
</div>
|
||||
|
||||
<q-form class="q-gutter-md" @submit="saveTargets">
|
||||
<div
|
||||
class="q-gutter-md row items-start"
|
||||
style="flex-wrap: nowrap"
|
||||
v-for="(target, t) in targets"
|
||||
>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
v-model="target.wallet"
|
||||
label="Wallet"
|
||||
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
|
||||
@input="targetChanged(false)"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
v-model="target.alias"
|
||||
label="Alias"
|
||||
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
|
||||
@input="targetChanged(false)"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
v-model.number="target.percent"
|
||||
label="Split Share"
|
||||
:hint="t === targets.length - 1 ? 'How much of the incoming payments will go to the target wallet.' : undefined"
|
||||
suffix="%"
|
||||
@input="targetChanged(true, t)"
|
||||
></q-input>
|
||||
</div>
|
||||
|
||||
<q-row class="row justify-evenly q-pa-lg">
|
||||
<q-col>
|
||||
<q-btn unelevated outline color="purple" @click="clearTargets">
|
||||
Clear
|
||||
</q-btn>
|
||||
</q-col>
|
||||
|
||||
<q-col>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
:disabled="!isDirty"
|
||||
>
|
||||
Save Targets
|
||||
</q-btn>
|
||||
</q-col>
|
||||
</q-row>
|
||||
</q-form>
|
||||
</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 SplitPayments extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "splitpayments/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/splitpayments/static/js/index.js"></script>
|
||||
{% endblock %}
|
12
lnbits/extensions/splitpayments/views.py
Normal file
12
lnbits/extensions/splitpayments/views.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import splitpayments_ext
|
||||
|
||||
|
||||
@splitpayments_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("splitpayments/index.html", user=g.user)
|
70
lnbits/extensions/splitpayments/views_api.py
Normal file
70
lnbits/extensions/splitpayments/views_api.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
from quart import g, jsonify
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.core.crud import get_wallet, get_wallet_for_key
|
||||
|
||||
from . import splitpayments_ext
|
||||
from .crud import get_targets, set_targets
|
||||
from .models import Target
|
||||
|
||||
|
||||
@splitpayments_ext.route("/api/v1/targets", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_targets_get():
|
||||
targets = await get_targets(g.wallet.id)
|
||||
return jsonify([target._asdict() for target in targets] or [])
|
||||
|
||||
|
||||
@splitpayments_ext.route("/api/v1/targets", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"targets": {
|
||||
"type": "list",
|
||||
"schema": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"wallet": {"type": "string"},
|
||||
"alias": {"type": "string"},
|
||||
"percent": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
async def api_targets_set():
|
||||
targets = []
|
||||
|
||||
for entry in g.data["targets"]:
|
||||
wallet = await get_wallet(entry["wallet"])
|
||||
if not wallet:
|
||||
wallet = await get_wallet_for_key(entry["wallet"], "invoice")
|
||||
if not wallet:
|
||||
return (
|
||||
jsonify({"message": f"Invalid wallet '{entry['wallet']}'."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if wallet.id == g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Can't split to itself."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if entry["percent"] < 0:
|
||||
return (
|
||||
jsonify({"message": f"Invalid percent '{entry['percent']}'."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
targets.append(
|
||||
Target(wallet.id, g.wallet.id, entry["percent"], entry["alias"] or "")
|
||||
)
|
||||
|
||||
percent_sum = sum([target.percent for target in targets])
|
||||
if percent_sum > 100:
|
||||
return jsonify({"message": "Splitting over 100%."}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
await set_targets(g.wallet.id, targets)
|
||||
return "", HTTPStatus.OK
|
|
@ -1,27 +1,29 @@
|
|||
<h1>Subdomains Extension</h1>
|
||||
|
||||
So the goal of the extension is to allow the owner of a domain to sell their subdomain to the anyone who is willing to pay some money for it.
|
||||
So the goal of the extension is to allow the owner of a domain to sell subdomains to anyone who is willing to pay some money for it.
|
||||
|
||||
[](https://youtu.be/O1X0fy3uNpw 'video tutorial subdomains')
|
||||
|
||||
## Requirements
|
||||
|
||||
- Free cloudflare account
|
||||
- Cloudflare as a dns server provider
|
||||
- Cloudflare TOKEN and Cloudflare zone-id where the domain is parked
|
||||
- Free Cloudflare account
|
||||
- Cloudflare as a DNS server provider
|
||||
- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
|
||||
|
||||
## Usage
|
||||
|
||||
1. Register at cloudflare and setup your domain with them. (Just follow instructions they provide...)
|
||||
2. Change DNS server at your domain registrar to point to cloudflare's
|
||||
3. Get Cloudflare zoneID for your domain
|
||||
1. Register at Cloudflare and setup your domain with them. (Just follow instructions they provide...)
|
||||
2. Change DNS server at your domain registrar to point to Cloudflare's
|
||||
3. Get Cloudflare zone-ID for your domain
|
||||
<img src="https://i.imgur.com/xOgapHr.png">
|
||||
4. get Cloudflare API TOKEN
|
||||
4. Get Cloudflare API TOKEN
|
||||
<img src="https://i.imgur.com/BZbktTy.png">
|
||||
<img src="https://i.imgur.com/YDZpW7D.png">
|
||||
5. Open the lnbits subdomains extension and register your domain with lnbits
|
||||
5. Open the LNBits subdomains extension and register your domain
|
||||
6. Click on the button in the table to open the public form that was generated for your domain
|
||||
|
||||
- Extension also supports webhooks so you can get notified when someone buys a new domain
|
||||
<img src="https://i.imgur.com/hiauxeR.png">
|
||||
- Extension also supports webhooks so you can get notified when someone buys a new subdomain\
|
||||
<img src="https://i.imgur.com/hiauxeR.png">
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
@ -36,8 +38,6 @@ So the goal of the extension is to allow the owner of a domain to sell their sub
|
|||
- GET /api/v1/subdomains/<payment_hash>
|
||||
- DELETE /api/v1/subdomains/<subdomain_id>
|
||||
|
||||
## Useful
|
||||
|
||||
### Cloudflare
|
||||
|
||||
- Cloudflare offers programmatic subdomain registration... (create new A record)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from http import HTTPStatus
|
||||
from quart.json import jsonify
|
||||
import trio # type: ignore
|
||||
import trio
|
||||
import httpx
|
||||
|
||||
from .crud import get_domain, set_subdomain_paid
|
||||
|
|
|
@ -452,7 +452,10 @@
|
|||
id: this.domainDialog.data.wallet
|
||||
})
|
||||
var data = this.domainDialog.data
|
||||
data.allowed_record_types = data.allowed_record_types.join(', ')
|
||||
data.allowed_record_types =
|
||||
typeof data.allowed_record_types === 'string'
|
||||
? data.allowed_record_types
|
||||
: data.allowed_record_types.join(', ')
|
||||
console.log(this.domainDialog)
|
||||
if (data.id) {
|
||||
this.updateDomain(wallet, data)
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
<h1> TPOS</h1>
|
||||
<h2>A Shareable PoS that doesnt need to be installed and can run in phones browser!</h2>
|
||||
<p>If someone drops your Quickening in a beer at your bar, staff can scan a QR and open/use a browser based PoS, linked to the same self-hosted lnbits instance!</p>
|
||||
# TPoS
|
||||
|
||||
<img src="https://i.imgur.com/8wgTWDn.jpg">
|
||||
## A Shareable PoS (Point of Sale) that doesn't need to be installed and can run in the browser!
|
||||
|
||||
An easy, fast and secure way to accept Bitcoin, over Lightning Network, at your business. The PoS is isolated from the wallet, so it's safe for any employee to use. You can create as many TPOS's as you need, for example one for each employee, or one for each branch of your business.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Enable extension
|
||||
2. Create a TPOS\
|
||||

|
||||
3. Open TPOS on the browser\
|
||||

|
||||
4. Present invoice QR to costumer\
|
||||

|
||||
|
|
|
@ -1,3 +1,26 @@
|
|||
<h1>User Manager</h1>
|
||||
<h2>Make and manager users/wallets</h2>
|
||||
To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets. For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the develpoers stack as the user and wallet manager.
|
||||
# User Manager
|
||||
|
||||
## Make and manage users/wallets
|
||||
|
||||
To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets.
|
||||
|
||||
For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the developers stack as the user and wallet manager. Or someone wanting to manage their family's wallets (wife, children, parents, etc...) or you want to host a community Lightning Network node and want to manage wallets for the users.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Click the button "NEW USER" to create a new user\
|
||||

|
||||
2. Fill the user information\
|
||||
- username
|
||||
- the generated wallet name, user can create other wallets later on
|
||||
- email
|
||||
- set a password
|
||||

|
||||
3. After creating your user, it will appear in the **Users** section, and a user's wallet in the **Wallets** section.
|
||||
4. Next you can share the wallet with the corresponding user\
|
||||

|
||||
5. If you need to create more wallets for some user, click "NEW WALLET" at the top\
|
||||

|
||||
- select the existing user you wish to add the wallet
|
||||
- set a wallet name\
|
||||

|
||||
|
|
|
@ -17,7 +17,11 @@ from .models import Users, Wallets
|
|||
|
||||
|
||||
async def create_usermanager_user(
|
||||
user_name: str, wallet_name: str, admin_id: str
|
||||
user_name: str,
|
||||
wallet_name: str,
|
||||
admin_id: str,
|
||||
email: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> Users:
|
||||
account = await create_account()
|
||||
user = await get_user(account.id)
|
||||
|
@ -27,10 +31,10 @@ async def create_usermanager_user(
|
|||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO users (id, name, admin)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO users (id, name, admin, email, password)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user.id, user_name, admin_id),
|
||||
(user.id, user_name, admin_id, email, password),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
|
|
|
@ -48,6 +48,26 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET user">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/usermanager/api/v1/users/<user_id></code
|
||||
>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>JSON list of users</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/users/<user_id> -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 wallets">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
@ -114,7 +134,8 @@
|
|||
</h5>
|
||||
<code
|
||||
>{"admin_id": <string>, "user_name": <string>,
|
||||
"wallet_name": <string>}</code
|
||||
"wallet_name": <string>,"email": <Optional string>
|
||||
,"password": <Optional string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
|
@ -128,7 +149,8 @@
|
|||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
|
||||
g.user.id }}", "wallet_name": <string>, "user_name":
|
||||
<string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||
<string>, "email": <Optional string>, "password": <
|
||||
Optional string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||
"Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
|
|
|
@ -157,6 +157,18 @@
|
|||
v-model.trim="userDialog.data.walname"
|
||||
label="Initial wallet name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="userDialog.data.email"
|
||||
label="Email"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="userDialog.data.password"
|
||||
label="Password"
|
||||
></q-input>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
|
@ -202,7 +214,7 @@
|
|||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapUserManager = function (obj) {
|
||||
var mapUserManager = function(obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
|
@ -216,7 +228,7 @@
|
|||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
data: function() {
|
||||
return {
|
||||
wallets: [],
|
||||
users: [],
|
||||
|
@ -224,7 +236,14 @@
|
|||
usersTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Username', field: 'name'}
|
||||
{name: 'name', align: 'left', label: 'Username', field: 'name'},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{
|
||||
name: 'password',
|
||||
align: 'left',
|
||||
label: 'Password',
|
||||
field: 'password'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
|
@ -258,8 +277,8 @@
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
userOptions: function () {
|
||||
return this.users.map(function (obj) {
|
||||
userOptions: function() {
|
||||
return this.users.map(function(obj) {
|
||||
console.log(obj.id)
|
||||
return {
|
||||
value: String(obj.id),
|
||||
|
@ -271,7 +290,7 @@
|
|||
methods: {
|
||||
///////////////Users////////////////////////////
|
||||
|
||||
getUsers: function () {
|
||||
getUsers: function() {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
|
@ -280,26 +299,28 @@
|
|||
'/usermanager/api/v1/users',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = response.data.map(function (obj) {
|
||||
.then(function(response) {
|
||||
self.users = response.data.map(function(obj) {
|
||||
return mapUserManager(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
openUserUpdateDialog: function (linkId) {
|
||||
openUserUpdateDialog: function(linkId) {
|
||||
var link = _.findWhere(this.users, {id: linkId})
|
||||
|
||||
this.userDialog.data = _.clone(link._data)
|
||||
this.userDialog.show = true
|
||||
},
|
||||
sendUserFormData: function () {
|
||||
sendUserFormData: function() {
|
||||
if (this.userDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
admin_id: this.g.user.id,
|
||||
user_name: this.userDialog.data.usrname,
|
||||
wallet_name: this.userDialog.data.walname
|
||||
wallet_name: this.userDialog.data.walname,
|
||||
email: this.userDialog.data.email,
|
||||
password: this.userDialog.data.password
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -308,7 +329,7 @@
|
|||
}
|
||||
},
|
||||
|
||||
createUser: function (data) {
|
||||
createUser: function(data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
|
@ -317,47 +338,48 @@
|
|||
this.g.user.wallets[0].inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
.then(function(response) {
|
||||
self.users.push(mapUserManager(response.data))
|
||||
self.userDialog.show = false
|
||||
self.userDialog.data = {}
|
||||
data = {}
|
||||
self.getWallets()
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteUser: function (userId) {
|
||||
deleteUser: function(userId) {
|
||||
var self = this
|
||||
|
||||
console.log(userId)
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this User link?')
|
||||
.onOk(function () {
|
||||
.onOk(function() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/usermanager/api/v1/users/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = _.reject(self.users, function (obj) {
|
||||
.then(function(response) {
|
||||
self.users = _.reject(self.users, function(obj) {
|
||||
return obj.id == userId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
exportUsersCSV: function () {
|
||||
exportUsersCSV: function() {
|
||||
LNbits.utils.exportCSV(this.usersTable.columns, this.users)
|
||||
},
|
||||
|
||||
///////////////Wallets////////////////////////////
|
||||
|
||||
getWallets: function () {
|
||||
getWallets: function() {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
|
@ -366,19 +388,19 @@
|
|||
'/usermanager/api/v1/wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = response.data.map(function (obj) {
|
||||
.then(function(response) {
|
||||
self.wallets = response.data.map(function(obj) {
|
||||
return mapUserManager(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openWalletUpdateDialog: function (linkId) {
|
||||
openWalletUpdateDialog: function(linkId) {
|
||||
var link = _.findWhere(this.users, {id: linkId})
|
||||
|
||||
this.walletDialog.data = _.clone(link._data)
|
||||
this.walletDialog.show = true
|
||||
},
|
||||
sendWalletFormData: function () {
|
||||
sendWalletFormData: function() {
|
||||
if (this.walletDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
|
@ -393,7 +415,7 @@
|
|||
}
|
||||
},
|
||||
|
||||
createWallet: function (data) {
|
||||
createWallet: function(data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
|
@ -402,43 +424,43 @@
|
|||
this.g.user.wallets[0].inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
.then(function(response) {
|
||||
self.wallets.push(mapUserManager(response.data))
|
||||
self.walletDialog.show = false
|
||||
self.walletDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteWallet: function (userId) {
|
||||
deleteWallet: function(userId) {
|
||||
var self = this
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet link?')
|
||||
.onOk(function () {
|
||||
.onOk(function() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/usermanager/api/v1/wallets/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = _.reject(self.wallets, function (obj) {
|
||||
.then(function(response) {
|
||||
self.wallets = _.reject(self.wallets, function(obj) {
|
||||
return obj.id == userId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function(error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportWalletsCSV: function () {
|
||||
exportWalletsCSV: function() {
|
||||
LNbits.utils.exportCSV(this.walletsTable.columns, this.wallets)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
created: function() {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getUsers()
|
||||
this.getWallets()
|
||||
|
|
|
@ -33,19 +33,29 @@ async def api_usermanager_users():
|
|||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/users/<user_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_user(user_id):
|
||||
user = await get_usermanager_user(user_id)
|
||||
return (
|
||||
jsonify(user._asdict()),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/users", methods=["POST"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"admin_id": {"type": "string", "empty": False, "required": True},
|
||||
"user_name": {"type": "string", "empty": False, "required": True},
|
||||
"wallet_name": {"type": "string", "empty": False, "required": True},
|
||||
"admin_id": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "required": False},
|
||||
"password": {"type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
async def api_usermanager_users_create():
|
||||
user = await create_usermanager_user(
|
||||
g.data["user_name"], g.data["wallet_name"], g.data["admin_id"]
|
||||
)
|
||||
user = await create_usermanager_user(**g.data)
|
||||
return jsonify(user._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,19 @@
|
|||
# Watch Only wallet
|
||||
|
||||
## Monitor an onchain wallet and generate addresses for onchain payments
|
||||
|
||||
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||
|
||||
1. Start by clicking "NEW WALLET"\
|
||||

|
||||
2. Fill the requested fields:
|
||||
- give the wallet a name
|
||||
- paste an Extended Public Key (xpub, ypub, zpub)
|
||||
- click "CREATE WATCH-ONLY WALLET"\
|
||||

|
||||
3. You can then access your onchain addresses\
|
||||

|
||||
4. You can then generate bitcoin onchain adresses from LNbits\
|
||||

|
||||
|
||||
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
|
|
|
@ -1,14 +1,46 @@
|
|||
# LNURLw
|
||||
## Withdraw link maker
|
||||
LNURL withdraw is a very powerful tool and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. This functionality has not existed in money before.
|
||||
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md#3-lnurl-withdraw
|
||||
|
||||
With this extension to can create/edit LNURL withdraws, set a min/max amount, set time (useful for subscription services)
|
||||
## Create a static QR code people can use to withdraw funds from a Lightning Network wallet
|
||||
|
||||

|
||||
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet.
|
||||
|
||||
The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone.
|
||||
|
||||
## API endpoint - /withdraw/api/v1/lnurlmaker
|
||||
Easily fetch one-off LNURLw
|
||||
LNURL withdraw is a **very powerful tool** and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. **This functionality has not existed in money before**.
|
||||
|
||||
curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/withdraw/api/v1/lnurlmaker -d '{"amount":"100","memo":"ATM"}' -H "X-Api-Key: YOUR-WALLET-ADMIN-KEY"
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
#### Quick Vouchers
|
||||
|
||||
LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
|
||||
|
||||
1. Create Quick Vouchers\
|
||||

|
||||
- select wallet
|
||||
- set the amount each voucher will allow someone to withdraw
|
||||
- set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_
|
||||
2. You can now print, share, display your LNURLw links or QR codes\
|
||||

|
||||
- on details you can print the vouchers\
|
||||

|
||||
- every printed LNURLw QR code is unique, it can only be used once
|
||||
|
||||
#### Advanced
|
||||
|
||||
1. Create the Advanced LNURLw\
|
||||

|
||||
- set the wallet
|
||||
- set a title for the LNURLw (it will show up in users wallet)
|
||||
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
|
||||
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
|
||||
- LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
|
||||
- you can set the time in _seconds, minutes or hours_
|
||||
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
|
||||
2. Print, share or display your LNURLw link or it's QR code\
|
||||

|
||||
|
||||
**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet!
|
||||
|
||||

|
||||
|
|
|
@ -98,19 +98,21 @@ async def api_lnurl_callback(unique_hash):
|
|||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=link.wallet,
|
||||
payment_request=payment_request,
|
||||
max_sat=link.max_withdrawable,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
|
||||
try:
|
||||
usescsv = ""
|
||||
for x in range(1, link.uses - link.used):
|
||||
usecv = link.usescsv.split(",")
|
||||
usescsv += "," + str(usecv[x])
|
||||
usecsvback = usescsv
|
||||
usescsv = usescsv[1:]
|
||||
|
||||
changesback = {
|
||||
"open_time": link.wait_time,
|
||||
"used": link.used,
|
||||
"usescsv": usecsvback,
|
||||
}
|
||||
|
||||
changes = {
|
||||
"open_time": link.wait_time + now,
|
||||
"used": link.used + 1,
|
||||
|
@ -118,11 +120,21 @@ async def api_lnurl_callback(unique_hash):
|
|||
}
|
||||
|
||||
await update_withdraw_link(link.id, **changes)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=link.wallet,
|
||||
payment_request=payment_request,
|
||||
max_sat=link.max_withdrawable,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
except ValueError as e:
|
||||
await update_withdraw_link(link.id, **changesback)
|
||||
return jsonify({"status": "ERROR", "reason": str(e)})
|
||||
except PermissionError:
|
||||
await update_withdraw_link(link.id, **changesback)
|
||||
return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."})
|
||||
except Exception as e:
|
||||
await update_withdraw_link(link.id, **changesback)
|
||||
return jsonify({"status": "ERROR", "reason": str(e)})
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
||||
|
|
|
@ -14,11 +14,18 @@ window.LNbits = {
|
|||
data: data
|
||||
})
|
||||
},
|
||||
createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
|
||||
createInvoice: async function (
|
||||
wallet,
|
||||
amount,
|
||||
memo,
|
||||
unit = 'sat',
|
||||
lnurlCallback = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||
out: false,
|
||||
amount: amount,
|
||||
memo: memo,
|
||||
unit: unit,
|
||||
lnurl_callback: lnurlCallback
|
||||
})
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import time
|
||||
import trio # type: ignore
|
||||
import trio
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, List, Callable
|
||||
from quart_trio import QuartTrio
|
||||
|
@ -13,13 +13,6 @@ from lnbits.core.crud import (
|
|||
)
|
||||
from lnbits.core.services import redeem_lnurl_withdraw
|
||||
|
||||
main_app: Optional[QuartTrio] = None
|
||||
|
||||
|
||||
def grab_app_for_later(app: QuartTrio):
|
||||
global main_app
|
||||
main_app = app
|
||||
|
||||
|
||||
deferred_async: List[Callable] = []
|
||||
|
||||
|
|
|
@ -86,6 +86,8 @@
|
|||
<q-toolbar-title class="text-caption">
|
||||
<strong>LN</strong>bits, free and open-source lightning
|
||||
wallet/accounts system
|
||||
<br />
|
||||
<small>Commit version: {{LNBITS_VERSION}}</small>
|
||||
</q-toolbar-title>
|
||||
<q-space></q-space>
|
||||
<q-btn
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import httpx
|
||||
from typing import Callable, NamedTuple
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ try:
|
|||
except ImportError: # pragma: nocover
|
||||
LightningRpc = None
|
||||
|
||||
import trio # type: ignore
|
||||
import trio
|
||||
import random
|
||||
import json
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import httpx
|
||||
from os import getenv
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import httpx
|
||||
import json
|
||||
import base64
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import json
|
||||
import trio # type: ignore
|
||||
import trio
|
||||
import httpx
|
||||
from os import getenv
|
||||
from http import HTTPStatus
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import httpx
|
||||
from os import getenv
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import hmac
|
||||
import httpx
|
||||
from http import HTTPStatus
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import trio # type: ignore
|
||||
import trio
|
||||
import json
|
||||
import httpx
|
||||
import random
|
||||
|
|
2
mypy.ini
Normal file
2
mypy.ini
Normal file
|
@ -0,0 +1,2 @@
|
|||
[mypy]
|
||||
plugins = trio_typing.plugin
|
Loading…
Add table
Reference in a new issue