Merge branch 'main' into diagon-alley
This commit is contained in:
commit
7037793369
106 changed files with 2970 additions and 1136 deletions
24
.env.example
24
.env.example
|
|
@ -11,6 +11,9 @@ LNBITS_ADMIN_USERS=""
|
||||||
LNBITS_ADMIN_EXTENSIONS="nostradmin"
|
LNBITS_ADMIN_EXTENSIONS="nostradmin"
|
||||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||||
|
|
||||||
|
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
|
||||||
|
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
|
||||||
|
|
||||||
# Disable extensions for all users, use "all" to disable all extensions
|
# Disable extensions for all users, use "all" to disable all extensions
|
||||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||||
|
|
||||||
|
|
@ -29,11 +32,12 @@ LNBITS_SERVICE_FEE="0.0"
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||||
# Choose from mint, flamingo, salvador, autumn, monochrome, classic
|
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
||||||
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
|
||||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
|
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
@ -50,14 +54,6 @@ CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||||
|
|
||||||
# LndWallet
|
|
||||||
LND_GRPC_ENDPOINT=127.0.0.1
|
|
||||||
LND_GRPC_PORT=11009
|
|
||||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
|
||||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
|
||||||
# To use an AES-encrypted macaroon, set
|
|
||||||
# LND_GRPC_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
|
||||||
|
|
||||||
# LndRestWallet
|
# LndRestWallet
|
||||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||||
|
|
@ -83,3 +79,7 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||||
# FakeWallet
|
# FakeWallet
|
||||||
FAKE_WALLET_SECRET="ToTheMoon1"
|
FAKE_WALLET_SECRET="ToTheMoon1"
|
||||||
LNBITS_DENOMINATION=sats
|
LNBITS_DENOMINATION=sats
|
||||||
|
|
||||||
|
# EclairWallet
|
||||||
|
ECLAIR_URL=http://127.0.0.1:8283
|
||||||
|
ECLAIR_PASS=eclairpw
|
||||||
1
.github/workflows/mypy.yml
vendored
1
.github/workflows/mypy.yml
vendored
|
|
@ -9,4 +9,5 @@ jobs:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
- uses: jpetrucciani/mypy-check@master
|
- uses: jpetrucciani/mypy-check@master
|
||||||
with:
|
with:
|
||||||
|
mypy_flags: '--install-types --non-interactive'
|
||||||
path: lnbits
|
path: lnbits
|
||||||
|
|
|
||||||
58
.github/workflows/on-push.yml
vendored
58
.github/workflows/on-push.yml
vendored
|
|
@ -1,58 +0,0 @@
|
||||||
name: Docker build on push
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
name: Build and push lnbits image
|
|
||||||
steps:
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Checkout project
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
id: qemu
|
|
||||||
|
|
||||||
- name: Setup Docker buildx action
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
id: buildx
|
|
||||||
|
|
||||||
- name: Show available Docker buildx platforms
|
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
|
||||||
|
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v2
|
|
||||||
id: cache
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
|
|
||||||
- name: Run Docker buildx against commit hash
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
|
||||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
|
||||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
|
||||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
|
|
||||||
--output "type=registry" ./
|
|
||||||
|
|
||||||
- name: Run Docker buildx against latest
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
|
||||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
|
||||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
|
||||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \
|
|
||||||
--output "type=registry" ./
|
|
||||||
68
.github/workflows/on-tag.yml
vendored
Normal file
68
.github/workflows/on-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: Build and push Docker image on tag
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
name: Build and push lnbits image
|
||||||
|
steps:
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Checkout project
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Import environment variables
|
||||||
|
id: import-env
|
||||||
|
shell: bash
|
||||||
|
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Show set environment variables
|
||||||
|
run: |
|
||||||
|
printf " TAG: %s\n" "$TAG"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
id: qemu
|
||||||
|
|
||||||
|
- name: Setup Docker buildx action
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
id: buildx
|
||||||
|
|
||||||
|
- name: Show available Docker buildx platforms
|
||||||
|
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v2
|
||||||
|
id: cache
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Run Docker buildx against tag
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||||
|
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
|
||||||
|
--output "type=registry" ./
|
||||||
|
|
||||||
|
- name: Run Docker buildx against latest
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||||
|
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
||||||
|
--output "type=registry" ./
|
||||||
|
|
@ -8,8 +8,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
# Install build deps
|
# Install build deps
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y --no-install-recommends build-essential
|
RUN apt-get install -y --no-install-recommends build-essential pkg-config
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip
|
||||||
|
RUN pip install wheel
|
||||||
|
|
||||||
# Install runtime deps
|
# Install runtime deps
|
||||||
COPY requirements.txt /tmp/requirements.txt
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
|
@ -36,6 +37,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
COPY --chown=1000:1000 lnbits /app/lnbits
|
||||||
|
|
||||||
|
ENV LNBITS_PORT="5000"
|
||||||
|
ENV LNBITS_HOST="0.0.0.0"
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["uvicorn", "lnbits.__main__:app", "--port", "5000", "--host", "0.0.0.0"]
|
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ LNbits
|
||||||
|
|
||||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||||
|
|
||||||
|
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
|
||||||
|
|
||||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||||
|
|
||||||
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
||||||
|
|
|
||||||
1
conv.py
1
conv.py
|
|
@ -1,6 +1,7 @@
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Python script to migrate an LNbits SQLite DB to Postgres
|
# Python script to migrate an LNbits SQLite DB to Postgres
|
||||||
# All credits to @Fritz446 for the awesome work
|
# All credits to @Fritz446 for the awesome work
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ LNbits uses [Pipenv][pipenv] to manage Python packages.
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
cd lnbits-legend/
|
cd lnbits-legend/
|
||||||
|
|
||||||
|
sudo apt-get install pipenv
|
||||||
pipenv shell
|
pipenv shell
|
||||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
||||||
pipenv install --dev
|
pipenv install --dev
|
||||||
|
|
@ -19,6 +21,9 @@ pipenv install --dev
|
||||||
|
|
||||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
||||||
# pip install -U setuptools
|
# pip install -U setuptools
|
||||||
|
|
||||||
|
# install libffi/libpq in case "pipenv install" fails
|
||||||
|
# sudo apt-get install -y libffi-dev libpq-dev
|
||||||
```
|
```
|
||||||
## Running the server
|
## Running the server
|
||||||
|
|
||||||
|
|
@ -41,4 +46,7 @@ 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.
|
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||||
|
|
||||||
**Note**: We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy, if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
**Notes**:
|
||||||
|
|
||||||
|
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
||||||
|
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,20 @@ You might also need to install additional packages or perform additional setup s
|
||||||
## Important note
|
## Important note
|
||||||
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
|
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
|
||||||
|
|
||||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above.
|
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above. Additionally, your lnbits instance should run once on postgres to implement the database schema before the migration works:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# STOP LNbits
|
# STOP LNbits
|
||||||
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
|
||||||
python3 conv.py
|
|
||||||
|
|
||||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||||
# save and exit
|
# save and exit
|
||||||
|
|
||||||
|
# START LNbits
|
||||||
|
# STOP LNbits
|
||||||
|
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
||||||
|
python3 conv.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||||
|
|
@ -78,17 +81,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LNbits
|
Description=LNbits
|
||||||
#Wants=lnd.service # you can uncomment these lines if you know what you're doing
|
# you can uncomment these lines if you know what you're doing
|
||||||
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service)
|
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||||
|
#Wants=lnd.service
|
||||||
|
#After=lnd.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
|
# replace with the absolute path of your lnbits installation
|
||||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
|
WorkingDirectory=/home/bitcoin/lnbits
|
||||||
User=bitcoin # replace with the user that you're running lnbits on
|
# same here
|
||||||
|
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||||
|
# replace with the user that you're running lnbits on
|
||||||
|
User=bitcoin
|
||||||
Restart=always
|
Restart=always
|
||||||
TimeoutSec=120
|
TimeoutSec=120
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time
|
# this makes sure that you receive logs in real time
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroo
|
||||||
### LND (REST)
|
### LND (REST)
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||||
- `LND_REST_ENDPOINT`: ip_address
|
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||||
- `LND_REST_CERT`: /file/path/tls.cert
|
- `LND_REST_CERT`: /file/path/tls.cert
|
||||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import importlib
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
import warnings
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
import lnbits.settings
|
import lnbits.settings
|
||||||
|
|
@ -58,15 +60,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
async def validation_exception_handler(
|
async def validation_exception_handler(
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
):
|
):
|
||||||
|
# Only the browser sends "text/html" request
|
||||||
|
# not fail proof, but everything else get's a JSON response
|
||||||
|
|
||||||
|
if "text/html" in request.headers["accept"]:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html",
|
"error.html",
|
||||||
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
|
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||||
)
|
)
|
||||||
|
|
||||||
# return HTMLResponse(
|
return JSONResponse(
|
||||||
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=HTTPStatus.NO_CONTENT,
|
||||||
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
content={"detail": exc.errors()},
|
||||||
# )
|
)
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
# app.add_middleware(ASGIProxyFix)
|
# app.add_middleware(ASGIProxyFix)
|
||||||
|
|
@ -84,15 +90,16 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
def check_funding_source(app: FastAPI) -> None:
|
def check_funding_source(app: FastAPI) -> None:
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def check_wallet_status():
|
async def check_wallet_status():
|
||||||
|
while True:
|
||||||
error_message, balance = await WALLET.status()
|
error_message, balance = await WALLET.status()
|
||||||
if error_message:
|
if not error_message:
|
||||||
|
break
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||||
RuntimeWarning,
|
RuntimeWarning,
|
||||||
)
|
)
|
||||||
|
print("Retrying connection to backend in 5 seconds...")
|
||||||
sys.exit(4)
|
await asyncio.sleep(5)
|
||||||
else:
|
|
||||||
print(
|
print(
|
||||||
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
|
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
|
||||||
)
|
)
|
||||||
|
|
@ -167,9 +174,17 @@ def register_exception_handlers(app: FastAPI):
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def basic_error(request: Request, err):
|
async def basic_error(request: Request, err):
|
||||||
print("handled error", traceback.format_exc())
|
print("handled error", traceback.format_exc())
|
||||||
|
print("ERROR:", err)
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
traceback.print_exception(etype, err, tb)
|
traceback.print_exception(etype, err, tb)
|
||||||
exc = traceback.format_exc()
|
exc = traceback.format_exc()
|
||||||
|
|
||||||
|
if "text/html" in request.headers["accept"]:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": err}
|
"error.html", {"request": request, "err": err}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=HTTPStatus.NO_CONTENT,
|
||||||
|
content={"detail": err},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -348,9 +348,9 @@ def _trim_to_bytes(barr):
|
||||||
|
|
||||||
def _readable_scid(short_channel_id: int) -> str:
|
def _readable_scid(short_channel_id: int) -> str:
|
||||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||||
blockheight=((short_channel_id >> 40) & 0xffffff),
|
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||||
transactionindex=((short_channel_id >> 16) & 0xffffff),
|
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||||
outputindex=(short_channel_id & 0xffff),
|
outputindex=(short_channel_id & 0xFFFF),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,13 +180,18 @@ async def get_wallet_for_key(
|
||||||
|
|
||||||
|
|
||||||
async def get_standalone_payment(
|
async def get_standalone_payment(
|
||||||
checking_id_or_hash: str, conn: Optional[Connection] = None
|
checking_id_or_hash: str,
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
|
incoming: Optional[bool] = False,
|
||||||
) -> Optional[Payment]:
|
) -> Optional[Payment]:
|
||||||
|
clause: str = "checking_id = ? OR hash = ?"
|
||||||
|
if incoming:
|
||||||
|
clause = f"({clause}) AND amount > 0"
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
f"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
WHERE checking_id = ? OR hash = ?
|
WHERE {clause}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(checking_id_or_hash, checking_id_or_hash),
|
(checking_id_or_hash, checking_id_or_hash),
|
||||||
|
|
|
||||||
|
|
@ -85,18 +85,17 @@ async def pay_invoice(
|
||||||
description: str = "",
|
description: str = "",
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
invoice = bolt11.decode(payment_request)
|
||||||
|
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
|
||||||
if invoice.amount_msat == 0:
|
if invoice.amount_msat == 0:
|
||||||
raise ValueError("Amountless invoices not supported.")
|
raise ValueError("Amountless invoices not supported.")
|
||||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||||
raise ValueError("Amount in invoice is too high.")
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
wallet = await get_wallet(wallet_id, conn=conn)
|
|
||||||
|
|
||||||
# put all parameters that don't change here
|
# put all parameters that don't change here
|
||||||
PaymentKwargs = TypedDict(
|
PaymentKwargs = TypedDict(
|
||||||
"PaymentKwargs",
|
"PaymentKwargs",
|
||||||
|
|
@ -134,26 +133,20 @@ async def pay_invoice(
|
||||||
# the balance is enough in the next step
|
# the balance is enough in the next step
|
||||||
await create_payment(
|
await create_payment(
|
||||||
checking_id=temp_id,
|
checking_id=temp_id,
|
||||||
fee=-fee_reserve(invoice.amount_msat),
|
fee=-fee_reserve_msat,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
**payment_kwargs,
|
**payment_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# do the balance check if internal payment
|
# do the balance check
|
||||||
if internal_checking_id:
|
|
||||||
wallet = await get_wallet(wallet_id, conn=conn)
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
assert wallet
|
assert wallet
|
||||||
if wallet.balance_msat < 0:
|
if wallet.balance_msat < 0:
|
||||||
raise PermissionError("Insufficient balance.")
|
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||||
|
raise PaymentFailure(
|
||||||
# do the balance check if external payment
|
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||||
else:
|
|
||||||
if invoice.amount_msat > wallet.balance_msat - (
|
|
||||||
wallet.balance_msat / 100 * 2
|
|
||||||
):
|
|
||||||
raise PermissionError(
|
|
||||||
"LNbits requires you keep at least 2% reserve to cover potential routing fees."
|
|
||||||
)
|
)
|
||||||
|
raise PermissionError("Insufficient balance.")
|
||||||
|
|
||||||
if internal_checking_id:
|
if internal_checking_id:
|
||||||
# mark the invoice from the other side as not pending anymore
|
# mark the invoice from the other side as not pending anymore
|
||||||
|
|
@ -171,7 +164,9 @@ async def pay_invoice(
|
||||||
await internal_invoice_queue.put(internal_checking_id)
|
await internal_invoice_queue.put(internal_checking_id)
|
||||||
else:
|
else:
|
||||||
# actually pay the external invoice
|
# actually pay the external invoice
|
||||||
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||||
|
payment_request, fee_reserve_msat
|
||||||
|
)
|
||||||
if payment.checking_id:
|
if payment.checking_id:
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
await create_payment(
|
await create_payment(
|
||||||
|
|
@ -340,5 +335,6 @@ async def check_invoice_status(
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||||
def fee_reserve(amount_msat: int) -> int:
|
def fee_reserve(amount_msat: int) -> int:
|
||||||
return max(1000, int(amount_msat * 0.01))
|
return max(2000, int(amount_msat * 0.01))
|
||||||
|
|
|
||||||
|
|
@ -364,12 +364,12 @@ new Vue({
|
||||||
},
|
},
|
||||||
decodeRequest: function () {
|
decodeRequest: function () {
|
||||||
this.parse.show = true
|
this.parse.show = true
|
||||||
|
let req = this.parse.data.request.toLowerCase()
|
||||||
if (this.parse.data.request.startsWith('lightning:')) {
|
if (this.parse.data.request.startsWith('lightning:')) {
|
||||||
this.parse.data.request = this.parse.data.request.slice(10)
|
this.parse.data.request = this.parse.data.request.slice(10)
|
||||||
} else if (this.parse.data.request.startsWith('lnurl:')) {
|
} else if (this.parse.data.request.startsWith('lnurl:')) {
|
||||||
this.parse.data.request = this.parse.data.request.slice(6)
|
this.parse.data.request = this.parse.data.request.slice(6)
|
||||||
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
|
} else if (req.indexOf('lightning=lnurl1') !== -1) {
|
||||||
this.parse.data.request = this.parse.data.request
|
this.parse.data.request = this.parse.data.request
|
||||||
.split('lightning=')[1]
|
.split('lightning=')[1]
|
||||||
.split('&')[0]
|
.split('&')[0]
|
||||||
|
|
@ -618,10 +618,10 @@ new Vue({
|
||||||
},
|
},
|
||||||
updateWalletName: function () {
|
updateWalletName: function () {
|
||||||
let newName = this.newName
|
let newName = this.newName
|
||||||
|
let adminkey = this.g.wallet.adminkey
|
||||||
if (!newName || !newName.length) return
|
if (!newName || !newName.length) return
|
||||||
// let data = {name: newName}
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.inkey, {})
|
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
this.newName = ''
|
this.newName = ''
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -691,10 +691,7 @@ new Vue({
|
||||||
},
|
},
|
||||||
mounted: function () {
|
mounted: function () {
|
||||||
// show disclaimer
|
// show disclaimer
|
||||||
if (
|
if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
|
||||||
this.$refs.disclaimer &&
|
|
||||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
|
||||||
) {
|
|
||||||
this.disclaimerDialog.show = true
|
this.disclaimerDialog.show = true
|
||||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
<code
|
<code
|
||||||
>{"out": false, "amount": <int>, "memo": <string>}</code
|
>{"out": false, "amount": <int>, "memo": <string>, "unit": <string>, "webhook": <url:string>}</code
|
||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 201 CREATED (application/json)
|
Returns 201 CREATED (application/json)
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||||
"amount": <int>, "memo": <string>, "webhook":
|
"amount": <int>, "memo": <string>, "webhook":
|
||||||
<url:string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
<url:string>, "unit": <string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||||
"Content-type: application/json"</code
|
"Content-type: application/json"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="processing"
|
@click="processing"
|
||||||
type="a"
|
type="a"
|
||||||
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
|
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||||
>
|
>
|
||||||
Press to claim bitcoin
|
Press to claim bitcoin
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,10 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if HIDE_API %}
|
||||||
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
|
{% else %}
|
||||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -288,12 +292,16 @@
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|
||||||
{% if wallet.lnurlwithdraw_full %}
|
{% if wallet.lnurlwithdraw_full %}
|
||||||
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="crop_free"
|
||||||
|
label="Drain Funds"
|
||||||
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p>
|
<p>
|
||||||
This is an LNURL-withdraw QR code for slurping everything from
|
This is an LNURL-withdraw QR code for slurping everything
|
||||||
this wallet. Do not share with anyone.
|
from this wallet. Do not share with anyone.
|
||||||
</p>
|
</p>
|
||||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||||
<qrcode
|
<qrcode
|
||||||
|
|
@ -303,8 +311,8 @@
|
||||||
</a>
|
</a>
|
||||||
<p>
|
<p>
|
||||||
It is compatible with <code>balanceCheck</code> and
|
It is compatible with <code>balanceCheck</code> and
|
||||||
<code>balanceNotify</code> so your wallet may keep pulling the
|
<code>balanceNotify</code> so your wallet may keep pulling
|
||||||
funds continuously from here after the first withdraw.
|
the funds continuously from here after the first withdraw.
|
||||||
</p>
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -378,6 +386,13 @@
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||||
|
ADS.split(';') %}
|
||||||
|
<q-card>
|
||||||
|
<a href="{{ AD[0] }}"
|
||||||
|
><img width="100%" src="{{ AD[1] }}"
|
||||||
|
/></a> </q-card
|
||||||
|
>{% endfor %} {% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -417,9 +432,11 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="receive.data.amount"
|
v-model.number="receive.data.amount"
|
||||||
type="number"
|
:label="'Amount (' + receive.unit + ') *'"
|
||||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||||
:step="receive.unit != 'sat' ? '0.001' : '1'"
|
fill-mask="0"
|
||||||
|
reverse-fill-mask
|
||||||
|
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||||
:min="receive.minMax[0]"
|
:min="receive.minMax[0]"
|
||||||
:max="receive.minMax[1]"
|
:max="receive.minMax[1]"
|
||||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
|
|
@ -437,7 +454,7 @@
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
|
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<span v-if="receive.lnurl">
|
<span v-if="receive.lnurl">
|
||||||
|
|
@ -445,7 +462,9 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Create invoice </span>
|
<span v-else> Create invoice </span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<q-spinner
|
<q-spinner
|
||||||
v-if="receive.status == 'loading'"
|
v-if="receive.status == 'loading'"
|
||||||
|
|
@ -480,8 +499,8 @@
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<div v-if="parse.invoice">
|
<div v-if="parse.invoice">
|
||||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", ""))
|
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
||||||
/ 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||||
</h6>
|
</h6>
|
||||||
<h6 v-else class="q-my-none">
|
<h6 v-else class="q-my-none">
|
||||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||||
|
|
@ -495,13 +514,17 @@
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
<div v-if="canPay" class="row q-mt-lg">
|
<div v-if="canPay" class="row q-mt-lg">
|
||||||
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="row q-mt-lg">
|
<div v-else class="row q-mt-lg">
|
||||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||||
>Not enough funds!</q-btn
|
>Not enough funds!</q-btn
|
||||||
>
|
>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="parse.lnurlauth">
|
<div v-else-if="parse.lnurlauth">
|
||||||
|
|
@ -514,8 +537,8 @@
|
||||||
<p>
|
<p>
|
||||||
For every website and for every LNbits wallet, a new keypair will be
|
For every website and for every LNbits wallet, a new keypair will be
|
||||||
deterministically generated so your identity can't be tied to your
|
deterministically generated so your identity can't be tied to your
|
||||||
LNbits wallet or linked across websites. No other data will be shared
|
LNbits wallet or linked across websites. No other data will be
|
||||||
with {{ parse.lnurlauth.domain }}.
|
shared with {{ parse.lnurlauth.domain }}.
|
||||||
</p>
|
</p>
|
||||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||||
<p class="q-mx-xl">
|
<p class="q-mx-xl">
|
||||||
|
|
@ -535,7 +558,8 @@
|
||||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||||
parse.lnurlpay.maxSendable | msatoshiFormat }} {{LNBITS_DENOMINATION}}
|
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
||||||
|
{{LNBITS_DENOMINATION}}
|
||||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||||
<br />
|
<br />
|
||||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||||
|
|
@ -645,7 +669,10 @@
|
||||||
<q-dialog v-model="parse.camera.show">
|
<q-dialog v-model="parse.camera.show">
|
||||||
<q-card class="q-pa-lg q-pt-xl">
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
|
<qrcode-stream
|
||||||
|
@decode="decodeQR"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode-stream>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||||
|
|
@ -679,11 +706,10 @@
|
||||||
|
|
||||||
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
{% if service_fee > 0 %}
|
|
||||||
<div ref="disclaimer"></div>
|
|
||||||
<q-dialog v-model="disclaimerDialog.show">
|
<q-dialog v-model="disclaimerDialog.show">
|
||||||
<q-card class="q-pa-lg">
|
<q-card class="q-pa-lg">
|
||||||
<h6 class="q-my-md text-deep-purple">Warning</h6>
|
<h6 class="q-my-md text-primary">Warning</h6>
|
||||||
<p>
|
<p>
|
||||||
Login functionality to be released in v0.2, for now,
|
Login functionality to be released in v0.2, for now,
|
||||||
<strong
|
<strong
|
||||||
|
|
@ -693,10 +719,10 @@
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This service is in BETA, and we hold no responsibility for people losing
|
This service is in BETA, and we hold no responsibility for people losing
|
||||||
access to funds. To encourage you to run your own LNbits installation, any
|
access to funds. {% if service_fee > 0 %} To encourage you to run your
|
||||||
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
|
own LNbits installation, any balance on {% raw %}{{
|
||||||
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
|
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
||||||
week.
|
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -711,4 +737,5 @@
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
{% endif %} {% endblock %}
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Query, Request
|
from fastapi import Header, Query, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.param_functions import Depends
|
from fastapi.param_functions import Depends
|
||||||
from fastapi.params import Body
|
from fastapi.params import Body
|
||||||
|
|
@ -23,9 +23,11 @@ from lnbits.decorators import (
|
||||||
WalletInvoiceKeyChecker,
|
WalletInvoiceKeyChecker,
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
get_key_type,
|
get_key_type,
|
||||||
|
require_admin_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
|
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
currencies,
|
currencies,
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -34,13 +36,14 @@ from lnbits.utils.exchange_rates import (
|
||||||
|
|
||||||
from .. import core_app, db
|
from .. import core_app, db
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
create_payment,
|
||||||
get_payments,
|
get_payments,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
save_balance_check,
|
|
||||||
update_wallet,
|
|
||||||
create_payment,
|
|
||||||
get_wallet,
|
get_wallet,
|
||||||
|
get_wallet_for_key,
|
||||||
|
save_balance_check,
|
||||||
update_payment_status,
|
update_payment_status,
|
||||||
|
update_wallet,
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
InvoiceFailure,
|
InvoiceFailure,
|
||||||
|
|
@ -51,8 +54,6 @@ from ..services import (
|
||||||
perform_lnurlauth,
|
perform_lnurlauth,
|
||||||
)
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/wallet")
|
@core_app.get("/api/v1/wallet")
|
||||||
|
|
@ -98,7 +99,7 @@ async def api_update_balance(
|
||||||
|
|
||||||
@core_app.put("/api/v1/wallet/{new_name}")
|
@core_app.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
await update_wallet(wallet.wallet.id, new_name)
|
await update_wallet(wallet.wallet.id, new_name)
|
||||||
return {
|
return {
|
||||||
|
|
@ -123,8 +124,8 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
|
||||||
class CreateInvoiceData(BaseModel):
|
class CreateInvoiceData(BaseModel):
|
||||||
out: Optional[bool] = True
|
out: Optional[bool] = True
|
||||||
amount: int = Query(None, ge=1)
|
amount: float = Query(None, ge=0)
|
||||||
memo: str = None
|
memo: Optional[str] = None
|
||||||
unit: Optional[str] = "sat"
|
unit: Optional[str] = "sat"
|
||||||
description_hash: Optional[str] = None
|
description_hash: Optional[str] = None
|
||||||
lnurl_callback: Optional[str] = None
|
lnurl_callback: Optional[str] = None
|
||||||
|
|
@ -140,9 +141,9 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
memo = ""
|
memo = ""
|
||||||
else:
|
else:
|
||||||
description_hash = b""
|
description_hash = b""
|
||||||
memo = data.memo
|
memo = data.memo or LNBITS_SITE_TITLE
|
||||||
if data.unit == "sat":
|
if data.unit == "sat":
|
||||||
amount = data.amount
|
amount = int(data.amount)
|
||||||
else:
|
else:
|
||||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||||
amount = price_in_sats
|
amount = price_in_sats
|
||||||
|
|
@ -363,7 +364,13 @@ async def api_payments_sse(
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||||
async def api_payment(payment_hash):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
|
wallet = None
|
||||||
|
try:
|
||||||
|
if X_Api_Key.extra:
|
||||||
|
print("No key")
|
||||||
|
except:
|
||||||
|
wallet = await get_wallet_for_key(X_Api_Key)
|
||||||
payment = await get_standalone_payment(payment_hash)
|
payment = await get_standalone_payment(payment_hash)
|
||||||
await check_invoice_status(payment.wallet_id, payment_hash)
|
await check_invoice_status(payment.wallet_id, payment_hash)
|
||||||
payment = await get_standalone_payment(payment_hash)
|
payment = await get_standalone_payment(payment_hash)
|
||||||
|
|
@ -372,13 +379,23 @@ async def api_payment(payment_hash):
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
)
|
)
|
||||||
elif not payment.pending:
|
elif not payment.pending:
|
||||||
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
|
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await payment.check_pending()
|
await payment.check_pending()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
|
return {"paid": False, "details": payment}
|
||||||
return {"paid": False}
|
return {"paid": False}
|
||||||
|
|
||||||
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
|
return {
|
||||||
|
"paid": not payment.pending,
|
||||||
|
"preimage": payment.preimage,
|
||||||
|
"details": payment,
|
||||||
|
}
|
||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -500,14 +517,19 @@ async def api_lnurlscan(code: str):
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
class DecodePayment(BaseModel):
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/decode")
|
@core_app.post("/api/v1/payments/decode")
|
||||||
async def api_payments_decode(data: str = Query(None)):
|
async def api_payments_decode(data: DecodePayment):
|
||||||
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if data["data"][:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
url = lnurl.decode(data["data"])
|
url = lnurl.decode(payment_str)
|
||||||
return {"domain": url}
|
return {"domain": url}
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(data["data"])
|
invoice = bolt11.decode(payment_str)
|
||||||
return {
|
return {
|
||||||
"payment_hash": invoice.payment_hash,
|
"payment_hash": invoice.payment_hash,
|
||||||
"amount_msat": invoice.amount_msat,
|
"amount_msat": invoice.amount_msat,
|
||||||
|
|
@ -559,6 +581,6 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||||
return output
|
return output
|
||||||
else:
|
else:
|
||||||
output[data.from_.upper()] = data.amount
|
output[data.from_.upper()] = data.amount
|
||||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.to)
|
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||||
output["BTC"] = output["sats"] / 100000000
|
output["BTC"] = output["sats"] / 100000000
|
||||||
return output
|
return output
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.helpers import template_renderer, url_for
|
from lnbits.helpers import template_renderer, url_for
|
||||||
from lnbits.settings import (
|
from lnbits.settings import (
|
||||||
LNBITS_ALLOWED_USERS,
|
|
||||||
LNBITS_ADMIN_USERS,
|
LNBITS_ADMIN_USERS,
|
||||||
|
LNBITS_ALLOWED_USERS,
|
||||||
LNBITS_SITE_TITLE,
|
LNBITS_SITE_TITLE,
|
||||||
SERVICE_FEE,
|
SERVICE_FEE,
|
||||||
)
|
)
|
||||||
|
|
@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str):
|
||||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse)
|
@core_html_routes.get(
|
||||||
|
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||||
|
)
|
||||||
async def lnurlwallet(request: Request):
|
async def lnurlwallet(request: Request):
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
account = await create_account(conn=conn)
|
account = await create_account(conn=conn)
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,15 @@ class Database(Compat):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if os.path.isdir(LNBITS_DATA_FOLDER):
|
||||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||||
database_uri = f"sqlite:///{self.path}"
|
database_uri = f"sqlite:///{self.path}"
|
||||||
self.type = SQLITE
|
self.type = SQLITE
|
||||||
|
else:
|
||||||
|
raise NotADirectoryError(
|
||||||
|
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
|
||||||
|
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
|
||||||
|
)
|
||||||
|
|
||||||
self.schema = self.name
|
self.schema = self.name
|
||||||
if self.name.startswith("ext_"):
|
if self.name.startswith("ext_"):
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ from starlette.requests import Request
|
||||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||||
from lnbits.core.models import User, Wallet
|
from lnbits.core.models import User, Wallet
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
|
from lnbits.settings import (
|
||||||
|
LNBITS_ALLOWED_USERS,
|
||||||
|
LNBITS_ADMIN_USERS,
|
||||||
|
LNBITS_ADMIN_EXTENSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KeyChecker(SecurityBase):
|
class KeyChecker(SecurityBase):
|
||||||
|
|
@ -122,7 +126,7 @@ async def get_key_type(
|
||||||
# 0: admin
|
# 0: admin
|
||||||
# 1: invoice
|
# 1: invoice
|
||||||
# 2: invalid
|
# 2: invalid
|
||||||
pathname = r['path'].split('/')[1]
|
pathname = r["path"].split("/")[1]
|
||||||
|
|
||||||
if not api_key_header and not api_key_query:
|
if not api_key_header and not api_key_query:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
@ -133,8 +137,12 @@ async def get_key_type(
|
||||||
checker = WalletAdminKeyChecker(api_key=token)
|
checker = WalletAdminKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(0, checker.wallet)
|
wallet = WalletTypeInfo(0, checker.wallet)
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||||
|
)
|
||||||
return wallet
|
return wallet
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||||
|
|
@ -147,9 +155,13 @@ async def get_key_type(
|
||||||
try:
|
try:
|
||||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(0, checker.wallet)
|
wallet = WalletTypeInfo(1, checker.wallet)
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||||
|
)
|
||||||
return wallet
|
return wallet
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ async def get_copilot(copilot_id: str) -> Copilots:
|
||||||
|
|
||||||
async def get_copilots(user: str) -> List[Copilots]:
|
async def get_copilots(user: str) -> List[Copilots]:
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT * FROM copilot.newer_copilots WHERE user = ?", (user,)
|
'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
|
||||||
)
|
)
|
||||||
return [Copilots(**row) for row in rows]
|
return [Copilots(**row) for row in rows]
|
||||||
|
|
||||||
|
|
|
||||||
11
lnbits/extensions/discordbot/Pipfile
Normal file
11
lnbits/extensions/discordbot/Pipfile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[[source]]
|
||||||
|
url = "https://pypi.python.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.9"
|
||||||
34
lnbits/extensions/discordbot/README.md
Normal file
34
lnbits/extensions/discordbot/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Discord Bot
|
||||||
|
|
||||||
|
## Provide LNbits wallets for all your Discord users
|
||||||
|
|
||||||
|
_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_
|
||||||
|
|
||||||
|
The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance.
|
||||||
|
|
||||||
|
To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands):
|
||||||
|
|
||||||
|
`/create` Will create a wallet for the Discord user
|
||||||
|
- (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`/balance` Will show the balance of the users wallet.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`/tip @user [amount]` Will sent money from one user to another
|
||||||
|
- If the recieving user does not have a wallet, one will be created for them
|
||||||
|
- The receiving user will receive a direct message from the bot with a link to their wallet
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`/payme [amount] [description]` Will open an invoice that can be paid by any user
|
||||||
|
|
||||||
|

|
||||||
25
lnbits/extensions/discordbot/__init__.py
Normal file
25
lnbits/extensions/discordbot/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_discordbot")
|
||||||
|
|
||||||
|
discordbot_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/discordbot/static",
|
||||||
|
"app": StaticFiles(directory="lnbits/extensions/discordbot/static"),
|
||||||
|
"name": "discordbot_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"])
|
||||||
|
|
||||||
|
|
||||||
|
def discordbot_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/discordbot/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
6
lnbits/extensions/discordbot/config.json
Normal file
6
lnbits/extensions/discordbot/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Discord Bot",
|
||||||
|
"short_description": "Generate users and wallets",
|
||||||
|
"icon": "person_add",
|
||||||
|
"contributors": ["bitcoingamer21"]
|
||||||
|
}
|
||||||
123
lnbits/extensions/discordbot/crud.py
Normal file
123
lnbits/extensions/discordbot/crud.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from lnbits.core.crud import (
|
||||||
|
create_account,
|
||||||
|
create_wallet,
|
||||||
|
delete_wallet,
|
||||||
|
get_payments,
|
||||||
|
get_user,
|
||||||
|
)
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import CreateUserData, Users, Wallets
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
|
||||||
|
async def create_discordbot_user(data: CreateUserData) -> Users:
|
||||||
|
account = await create_account()
|
||||||
|
user = await get_user(account.id)
|
||||||
|
assert user, "Newly created user couldn't be retrieved"
|
||||||
|
|
||||||
|
wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO discordbot.users (id, name, admin, discord_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user.id, data.user_name, data.admin_id, data.discord_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
wallet.id,
|
||||||
|
data.admin_id,
|
||||||
|
data.wallet_name,
|
||||||
|
user.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
wallet.inkey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_created = await get_discordbot_user(user.id)
|
||||||
|
assert user_created, "Newly created user couldn't be retrieved"
|
||||||
|
return user_created
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_user(user_id: str) -> Optional[Users]:
|
||||||
|
row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,))
|
||||||
|
return Users(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_users(user_id: str) -> List[Users]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM discordbot.users WHERE admin = ?", (user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Users(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_discordbot_user(user_id: str) -> None:
|
||||||
|
wallets = await get_discordbot_wallets(user_id)
|
||||||
|
for wallet in wallets:
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,))
|
||||||
|
await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,))
|
||||||
|
|
||||||
|
|
||||||
|
### Wallets
|
||||||
|
|
||||||
|
|
||||||
|
async def create_discordbot_wallet(
|
||||||
|
user_id: str, wallet_name: str, admin_id: str
|
||||||
|
) -> Wallets:
|
||||||
|
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey),
|
||||||
|
)
|
||||||
|
wallet_created = await get_discordbot_wallet(wallet.id)
|
||||||
|
assert wallet_created, "Newly created wallet couldn't be retrieved"
|
||||||
|
return wallet_created
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,)
|
||||||
|
)
|
||||||
|
return Wallets(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
|
||||||
|
)
|
||||||
|
return [Wallets(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
|
||||||
|
)
|
||||||
|
return [Wallets(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||||
|
return await get_payments(
|
||||||
|
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_discordbot_wallet(wallet_id: str, user_id: str) -> None:
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet_id)
|
||||||
|
await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,))
|
||||||
30
lnbits/extensions/discordbot/migrations.py
Normal file
30
lnbits/extensions/discordbot/migrations.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial users table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE discordbot.users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
admin TEXT NOT NULL,
|
||||||
|
discord_id TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Initial wallets table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE discordbot.wallets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
adminkey TEXT NOT NULL,
|
||||||
|
inkey TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
38
lnbits/extensions/discordbot/models.py
Normal file
38
lnbits/extensions/discordbot/models.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from sqlite3 import Row
|
||||||
|
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserData(BaseModel):
|
||||||
|
user_name: str = Query(...)
|
||||||
|
wallet_name: str = Query(...)
|
||||||
|
admin_id: str = Query(...)
|
||||||
|
discord_id: str = Query("")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserWallet(BaseModel):
|
||||||
|
user_id: str = Query(...)
|
||||||
|
wallet_name: str = Query(...)
|
||||||
|
admin_id: str = Query(...)
|
||||||
|
|
||||||
|
|
||||||
|
class Users(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
admin: str
|
||||||
|
discord_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Wallets(BaseModel):
|
||||||
|
id: str
|
||||||
|
admin: str
|
||||||
|
name: str
|
||||||
|
user: str
|
||||||
|
adminkey: str
|
||||||
|
inkey: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Wallets":
|
||||||
|
return cls(**dict(row))
|
||||||
BIN
lnbits/extensions/discordbot/static/stack.png
Normal file
BIN
lnbits/extensions/discordbot/static/stack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
260
lnbits/extensions/discordbot/templates/discordbot/_api_docs.html
Normal file
260
lnbits/extensions/discordbot/templates/discordbot/_api_docs.html
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="Info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
Discord Bot: Connect Discord users to LNbits.
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
Connect your LNbits instance to a <a href="https://github.com/chrislennon/lnbits-discord-bot">Discord Bot</a> leveraging LNbits as a community based lightning node.<br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/chrislennon">Chris Lennon</a></small
|
||||||
|
> <br />
|
||||||
|
<small>
|
||||||
|
Based on User Manager, by <a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
|
>
|
||||||
|
</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="GET users">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
/discordbot/api/v1/users</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.base_url }}discordbot/api/v1/users -H
|
||||||
|
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</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>
|
||||||
|
/discordbot/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.base_url
|
||||||
|
}}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{
|
||||||
|
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>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
/discordbot/api/v1/wallets/<user_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</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 wallet data</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url
|
||||||
|
}}discordbot/api/v1/wallets/<user_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="GET transactions">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
/discordbot/api/v1/wallets<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</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 a wallets transactions</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url
|
||||||
|
}}discordbot/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="POST user + initial wallet"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-green">POST</span>
|
||||||
|
/discordbot/api/v1/users</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code
|
||||||
|
>{"X-Api-Key": <string>, "Content-type":
|
||||||
|
"application/json"}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json) - "admin_id" is a YOUR user ID
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"admin_id": <string>, "user_name": <string>,
|
||||||
|
"wallet_name": <string>,"discord_id": <string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"id": <string>, "name": <string>, "admin":
|
||||||
|
<string>, "discord_id": <string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url }}discordbot/api/v1/users -d
|
||||||
|
'{"admin_id": "{{ user.id }}", "wallet_name": <string>,
|
||||||
|
"user_name": <string>, "discord_id": <string>}' -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}" -H "Content-type: application/json"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="POST wallet">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-green">POST</span>
|
||||||
|
/discordbot/api/v1/wallets</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code
|
||||||
|
>{"X-Api-Key": <string>, "Content-type":
|
||||||
|
"application/json"}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json) - "admin_id" is a YOUR user ID
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"user_id": <string>, "wallet_name": <string>,
|
||||||
|
"admin_id": <string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"id": <string>, "admin": <string>, "name":
|
||||||
|
<string>, "user": <string>, "adminkey": <string>,
|
||||||
|
"inkey": <string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url }}discordbot/api/v1/wallets -d
|
||||||
|
'{"user_id": <string>, "wallet_name": <string>,
|
||||||
|
"admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey
|
||||||
|
}}" -H "Content-type: application/json"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="DELETE user and their wallets"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-red">DELETE</span>
|
||||||
|
/discordbot/api/v1/users/<user_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.base_url
|
||||||
|
}}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="DELETE wallet">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-red">DELETE</span>
|
||||||
|
/discordbot/api/v1/wallets/<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.base_url
|
||||||
|
}}discordbot/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="POST activate extension"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-green">POST</span>
|
||||||
|
/discordbot/api/v1/extensions</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url }}discordbot/api/v1/extensions -d
|
||||||
|
'{"userid": <string>, "extension": <string>, "active":
|
||||||
|
<integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H
|
||||||
|
"Content-type: application/json"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
464
lnbits/extensions/discordbot/templates/discordbot/index.html
Normal file
464
lnbits/extensions/discordbot/templates/discordbot/index.html
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
{% 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-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<center><img src="/discordbot/static/stack.png" height="200" /></center>
|
||||||
|
This extension is designed to be used through its API by a Discord Bot,
|
||||||
|
currently you have to install the bot
|
||||||
|
<a
|
||||||
|
href="https://github.com/chrislennon/lnbits-discord-bot/#installation"
|
||||||
|
>yourself</a
|
||||||
|
><br />
|
||||||
|
|
||||||
|
Soon™ there will be a much easier one-click install discord bot...
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Users</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportUsersCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="users"
|
||||||
|
row-key="id"
|
||||||
|
:columns="usersTable.columns"
|
||||||
|
:pagination.sync="usersTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteUser(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Wallets</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportWalletsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="wallets"
|
||||||
|
row-key="id"
|
||||||
|
:columns="walletsTable.columns"
|
||||||
|
:pagination.sync="walletsTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</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="account_balance_wallet"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="props.row.walllink"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
<q-tooltip> Link to wallet </q-tooltip>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteWallet(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits Discord Bot Extension
|
||||||
|
<!--{{SITE_TITLE}} Discord Bot Extension-->
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "discordbot/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="userDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="sendUserFormData" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="userDialog.data.usrname"
|
||||||
|
label="Username"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="userDialog.data.walname"
|
||||||
|
label="Initial wallet name"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="userDialog.data.discord_id"
|
||||||
|
label="Discord ID"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="userDialog.data.walname == null"
|
||||||
|
type="submit"
|
||||||
|
>Create User</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="walletDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="sendWalletFormData" class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="walletDialog.data.user"
|
||||||
|
:options="userOptions"
|
||||||
|
label="User *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="walletDialog.data.walname"
|
||||||
|
label="Wallet name"
|
||||||
|
></q-input>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="walletDialog.data.walname == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Wallet</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
var mapUserManager = function (obj) {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
|
obj.walllink = ['../wallet?usr=', obj.user, '&wal=', obj.id].join('')
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
wallets: [],
|
||||||
|
users: [],
|
||||||
|
|
||||||
|
usersTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Username', field: 'name'},
|
||||||
|
{name: 'discord_id', align: 'left', label: 'discord_id', field: 'discord_id'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
walletsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{name: 'user', align: 'left', label: 'User', field: 'user'},
|
||||||
|
{
|
||||||
|
name: 'adminkey',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Admin Key',
|
||||||
|
field: 'adminkey'
|
||||||
|
},
|
||||||
|
{name: 'inkey', align: 'left', label: 'Invoice Key', field: 'inkey'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
walletDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
userDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userOptions: function () {
|
||||||
|
return this.users.map(function (obj) {
|
||||||
|
console.log(obj.id)
|
||||||
|
return {
|
||||||
|
value: String(obj.id),
|
||||||
|
label: String(obj.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
///////////////Users////////////////////////////
|
||||||
|
|
||||||
|
getUsers: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/discordbot/api/v1/users',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.users = response.data.map(function (obj) {
|
||||||
|
return mapUserManager(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
openUserUpdateDialog: function (linkId) {
|
||||||
|
var link = _.findWhere(this.users, {id: linkId})
|
||||||
|
|
||||||
|
this.userDialog.data = _.clone(link._data)
|
||||||
|
this.userDialog.show = true
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
discord_id: this.userDialog.data.discord_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
this.createUser(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: function (data) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/discordbot/api/v1/users',
|
||||||
|
this.g.user.wallets[0].inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.users.push(mapUserManager(response.data))
|
||||||
|
self.userDialog.show = false
|
||||||
|
self.userDialog.data = {}
|
||||||
|
data = {}
|
||||||
|
self.getWallets()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteUser: function (userId) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
console.log(userId)
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this User link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/discordbot/api/v1/users/' + userId,
|
||||||
|
self.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.users = _.reject(self.users, function (obj) {
|
||||||
|
return obj.id == userId
|
||||||
|
})
|
||||||
|
self.getWallets()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
exportUsersCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.usersTable.columns, this.users)
|
||||||
|
},
|
||||||
|
|
||||||
|
///////////////Wallets////////////////////////////
|
||||||
|
|
||||||
|
getWallets: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/discordbot/api/v1/wallets',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.wallets = response.data.map(function (obj) {
|
||||||
|
return mapUserManager(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openWalletUpdateDialog: function (linkId) {
|
||||||
|
var link = _.findWhere(this.users, {id: linkId})
|
||||||
|
|
||||||
|
this.walletDialog.data = _.clone(link._data)
|
||||||
|
this.walletDialog.show = true
|
||||||
|
},
|
||||||
|
sendWalletFormData: function () {
|
||||||
|
if (this.walletDialog.data.id) {
|
||||||
|
} else {
|
||||||
|
var data = {
|
||||||
|
user_id: this.walletDialog.data.user,
|
||||||
|
admin_id: this.g.user.id,
|
||||||
|
wallet_name: this.walletDialog.data.walname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
this.createWallet(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createWallet: function (data) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/discordbot/api/v1/wallets',
|
||||||
|
this.g.user.wallets[0].inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.wallets.push(mapUserManager(response.data))
|
||||||
|
self.walletDialog.show = false
|
||||||
|
self.walletDialog.data = {}
|
||||||
|
data = {}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteWallet: function (userId) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this wallet link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/discordbot/api/v1/wallets/' + userId,
|
||||||
|
self.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.wallets = _.reject(self.wallets, function (obj) {
|
||||||
|
return obj.id == userId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportWalletsCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.walletsTable.columns, this.wallets)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getUsers()
|
||||||
|
this.getWallets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
15
lnbits/extensions/discordbot/views.py
Normal file
15
lnbits/extensions/discordbot/views.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import discordbot_ext, discordbot_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return discordbot_renderer().TemplateResponse(
|
||||||
|
"discordbot/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
125
lnbits/extensions/discordbot/views_api.py
Normal file
125
lnbits/extensions/discordbot/views_api.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core import update_user_extension
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
|
from . import discordbot_ext
|
||||||
|
from .crud import (
|
||||||
|
create_discordbot_user,
|
||||||
|
create_discordbot_wallet,
|
||||||
|
delete_discordbot_user,
|
||||||
|
delete_discordbot_wallet,
|
||||||
|
get_discordbot_user,
|
||||||
|
get_discordbot_users,
|
||||||
|
get_discordbot_users_wallets,
|
||||||
|
get_discordbot_wallet,
|
||||||
|
get_discordbot_wallet_transactions,
|
||||||
|
get_discordbot_wallets,
|
||||||
|
)
|
||||||
|
from .models import CreateUserData, CreateUserWallet
|
||||||
|
|
||||||
|
# Users
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||||
|
async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
user_id = wallet.wallet.user
|
||||||
|
return [user.dict() for user in await get_discordbot_users(user_id)]
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
user = await get_discordbot_user(user_id)
|
||||||
|
return user.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
|
||||||
|
async def api_discordbot_users_create(
|
||||||
|
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
user = await create_discordbot_user(data)
|
||||||
|
full = user.dict()
|
||||||
|
full["wallets"] = [
|
||||||
|
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id)
|
||||||
|
]
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.delete("/api/v1/users/{user_id}")
|
||||||
|
async def api_discordbot_users_delete(
|
||||||
|
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
user = await get_discordbot_user(user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||||
|
)
|
||||||
|
await delete_discordbot_user(user_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
# Activate Extension
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.post("/api/v1/extensions")
|
||||||
|
async def api_discordbot_activate_extension(
|
||||||
|
extension: str = Query(...), userid: str = Query(...), active: bool = Query(...)
|
||||||
|
):
|
||||||
|
user = await get_user(userid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||||
|
)
|
||||||
|
update_user_extension(user_id=userid, extension=extension, active=active)
|
||||||
|
return {"extension": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
# Wallets
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.post("/api/v1/wallets")
|
||||||
|
async def api_discordbot_wallets_create(
|
||||||
|
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
user = await create_discordbot_wallet(
|
||||||
|
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
|
||||||
|
)
|
||||||
|
return user.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/api/v1/wallets")
|
||||||
|
async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
admin_id = wallet.wallet.user
|
||||||
|
return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)]
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/api/v1/transactions/{wallet_id}")
|
||||||
|
async def api_discordbot_wallet_transactions(
|
||||||
|
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
return await get_discordbot_wallet_transactions(wallet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.get("/api/v1/wallets/{user_id}")
|
||||||
|
async def api_discordbot_users_wallets(
|
||||||
|
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)]
|
||||||
|
|
||||||
|
|
||||||
|
@discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||||
|
async def api_discordbot_wallets_delete(
|
||||||
|
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
get_wallet = await get_discordbot_wallet(wallet_id)
|
||||||
|
if not get_wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||||
|
)
|
||||||
|
await delete_discordbot_wallet(wallet_id, get_wallet.user)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
@ -76,7 +76,7 @@ async def delete_ticket(payment_hash: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def delete_event_tickets(event_id: str) -> None:
|
async def delete_event_tickets(event_id: str) -> None:
|
||||||
await db.execute("DELETE FROM events.tickets WHERE event = ?", (event_id,))
|
await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
|
||||||
|
|
||||||
|
|
||||||
# EVENTS
|
# EVENTS
|
||||||
|
|
|
||||||
11
lnbits/extensions/example/README.md
Normal file
11
lnbits/extensions/example/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<h1>Example Extension</h1>
|
||||||
|
<h2>*tagline*</h2>
|
||||||
|
This is an example extension to help you organise and build you own.
|
||||||
|
|
||||||
|
Try to include an image
|
||||||
|
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||||
|
|
||||||
|
|
||||||
|
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
16
lnbits/extensions/example/__init__.py
Normal file
16
lnbits/extensions/example/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_example")
|
||||||
|
|
||||||
|
example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"])
|
||||||
|
|
||||||
|
|
||||||
|
def example_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/example/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
6
lnbits/extensions/example/example.config.json
Normal file
6
lnbits/extensions/example/example.config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Build your own!!",
|
||||||
|
"short_description": "Join us, make an extension",
|
||||||
|
"icon": "info",
|
||||||
|
"contributors": ["github_username"]
|
||||||
|
}
|
||||||
10
lnbits/extensions/example/migrations.py
Normal file
10
lnbits/extensions/example/migrations.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# async def m001_initial(db):
|
||||||
|
# await db.execute(
|
||||||
|
# f"""
|
||||||
|
# CREATE TABLE example.example (
|
||||||
|
# id TEXT PRIMARY KEY,
|
||||||
|
# wallet TEXT NOT NULL,
|
||||||
|
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
# );
|
||||||
|
# """
|
||||||
|
# )
|
||||||
5
lnbits/extensions/example/models.py
Normal file
5
lnbits/extensions/example/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# from pydantic import BaseModel
|
||||||
|
|
||||||
|
# class Example(BaseModel):
|
||||||
|
# id: str
|
||||||
|
# wallet: str
|
||||||
59
lnbits/extensions/example/templates/example/index.html
Normal file
59
lnbits/extensions/example/templates/example/index.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-subtitle1 q-mt-none q-mb-md">
|
||||||
|
Frameworks used by {{SITE_TITLE}}
|
||||||
|
</h5>
|
||||||
|
<q-list>
|
||||||
|
<q-item
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.name"
|
||||||
|
tag="a"
|
||||||
|
:href="tool.url"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<!-- with raw Flask won't try to interpret the Vue moustaches -->
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ tool.name }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ tool.language }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
{% endraw %}
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-separator class="q-my-lg"></q-separator>
|
||||||
|
<p>
|
||||||
|
A magical "g" is always available, with info about the user, wallets and
|
||||||
|
extensions:
|
||||||
|
</p>
|
||||||
|
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
tools: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
// axios is available for making requests
|
||||||
|
axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/example/api/v1/tools',
|
||||||
|
headers: {
|
||||||
|
'X-example-header': 'not-used'
|
||||||
|
}
|
||||||
|
}).then(function (response) {
|
||||||
|
self.tools = response.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
18
lnbits/extensions/example/views.py
Normal file
18
lnbits/extensions/example/views.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import example_ext, example_renderer
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@example_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return example_renderer().TemplateResponse(
|
||||||
|
"example/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
35
lnbits/extensions/example/views_api.py
Normal file
35
lnbits/extensions/example/views_api.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# views_api.py is for you API endpoints that could be hit by another service
|
||||||
|
|
||||||
|
# add your dependencies here
|
||||||
|
|
||||||
|
# import httpx
|
||||||
|
# (use httpx just like requests, except instead of response.ok there's only the
|
||||||
|
# response.is_error that is its inverse)
|
||||||
|
|
||||||
|
from . import example_ext
|
||||||
|
|
||||||
|
# add your endpoints here
|
||||||
|
|
||||||
|
|
||||||
|
@example_ext.get("/api/v1/tools")
|
||||||
|
async def api_example():
|
||||||
|
"""Try to add descriptions for others."""
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": "fastAPI",
|
||||||
|
"url": "https://fastapi.tiangolo.com/",
|
||||||
|
"language": "Python",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vue.js",
|
||||||
|
"url": "https://vuejs.org/",
|
||||||
|
"language": "JavaScript",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Quasar Framework",
|
||||||
|
"url": "https://quasar.dev/",
|
||||||
|
"language": "JavaScript",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
@ -12,7 +12,7 @@ async def create_jukebox(
|
||||||
juke_id = urlsafe_short_hash()
|
juke_id = urlsafe_short_hash()
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -41,6 +41,7 @@ async def update_jukebox(
|
||||||
q = ", ".join([f"{field[0]} = ?" for field in data])
|
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||||
items = [f"{field[1]}" for field in data]
|
items = [f"{field[1]}" for field in data]
|
||||||
items.append(juke_id)
|
items.append(juke_id)
|
||||||
|
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
|
||||||
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
||||||
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||||
return Jukebox(**row) if row else None
|
return Jukebox(**row) if row else None
|
||||||
|
|
@ -57,11 +58,11 @@ async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
|
||||||
|
|
||||||
|
|
||||||
async def get_jukeboxs(user: str) -> List[Jukebox]:
|
async def get_jukeboxs(user: str) -> List[Jukebox]:
|
||||||
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.sp_playlists == None:
|
if row.sp_playlists == None:
|
||||||
await delete_jukebox(row.id)
|
await delete_jukebox(row.id)
|
||||||
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
|
||||||
|
|
||||||
return [Jukebox(**row) for row in rows]
|
return [Jukebox(**row) for row in rows]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ async def api_check_credentials_check(
|
||||||
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
|
||||||
return jukebox
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -442,7 +441,7 @@ async def api_get_jukebox_currently(
|
||||||
token = await api_get_token(juke_id)
|
token = await api_get_token(juke_id)
|
||||||
if token == False:
|
if token == False:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="INvoice not paid"
|
status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
|
||||||
)
|
)
|
||||||
elif retry:
|
elif retry:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -456,5 +455,6 @@ async def api_get_jukebox_currently(
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Something went wrong, or no song is playing yet",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
# from mmap import MAP_DENYWRITE
|
# from mmap import MAP_DENYWRITE
|
||||||
|
|
||||||
from fastapi.param_functions import Depends
|
from fastapi.param_functions import Depends
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
{{ col.label }}
|
{{ col.label }}
|
||||||
|
|
@ -136,9 +137,19 @@
|
||||||
:href="'mailto:' + props.row.email"
|
:href="'mailto:' + props.row.email"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="launch"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="ticketCard(props)"
|
||||||
|
><q-tooltip> Click to show ticket </q-tooltip></q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
{{ col.value }}
|
{{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }}
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
|
|
@ -249,6 +260,29 @@
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
<!-- Read Ticket Dialog -->
|
||||||
|
<q-dialog v-model="ticketDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
{% raw %}
|
||||||
|
<q-card-section>
|
||||||
|
<h4 class="text-subtitle1 q-my-none">
|
||||||
|
<i>{{this.ticketDialog.data.name}}</i> sent a ticket
|
||||||
|
</h4>
|
||||||
|
<div v-if="this.ticketDialog.data.email">
|
||||||
|
<small>{{this.ticketDialog.data.email}}</small>
|
||||||
|
</div>
|
||||||
|
<small>{{this.ticketDialog.data.date}}</small>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section>
|
||||||
|
<p>{{this.ticketDialog.data.content}}</p>
|
||||||
|
</q-card-section>
|
||||||
|
{% endraw %}
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="CLOSE" color="primary" v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -318,6 +352,10 @@
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {flatrate: false}
|
data: {flatrate: false}
|
||||||
|
},
|
||||||
|
ticketDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -372,6 +410,16 @@
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
ticketCard(ticket){
|
||||||
|
this.ticketDialog.show = true
|
||||||
|
let {date, email, ltext, name} = ticket.row
|
||||||
|
this.ticketDialog.data = {
|
||||||
|
date,
|
||||||
|
email,
|
||||||
|
content: ltext,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
},
|
||||||
exportticketsCSV: function () {
|
exportticketsCSV: function () {
|
||||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||||
},
|
},
|
||||||
|
|
@ -421,12 +469,13 @@
|
||||||
},
|
},
|
||||||
updateformDialog: function (formId) {
|
updateformDialog: function (formId) {
|
||||||
var link = _.findWhere(this.forms, {id: formId})
|
var link = _.findWhere(this.forms, {id: formId})
|
||||||
|
console.log("LINK", link)
|
||||||
|
|
||||||
this.formDialog.data.id = link.id
|
this.formDialog.data.id = link.id
|
||||||
this.formDialog.data.wallet = link.wallet
|
this.formDialog.data.wallet = link.wallet
|
||||||
this.formDialog.data.name = link.name
|
this.formDialog.data.name = link.name
|
||||||
this.formDialog.data.description = link.description
|
this.formDialog.data.description = link.description
|
||||||
this.formDialog.data.flatrate = link.flatrate
|
this.formDialog.data.flatrate = Boolean(link.flatrate)
|
||||||
this.formDialog.data.amount = link.amount
|
this.formDialog.data.amount = link.amount
|
||||||
this.formDialog.show = true
|
this.formDialog.show = true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,10 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
|
||||||
)
|
)
|
||||||
|
if data.sats < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail=f"0 invoices not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
nwords = len(re.split(r"\s+", data.ltext))
|
nwords = len(re.split(r"\s+", data.ltext))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
|
|
||||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||||
method = db.execute if db.type == SQLITE else db.fetchone
|
method = db.execute if db.type == SQLITE else db.fetchone
|
||||||
|
# database only allows int4 entries for min and max. For fiat currencies,
|
||||||
|
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||||
|
if data.currency and data.fiat_base_multiplier:
|
||||||
|
data.min *= data.fiat_base_multiplier
|
||||||
|
data.max *= data.fiat_base_multiplier
|
||||||
|
|
||||||
result = await (method)(
|
result = await (method)(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO lnurlp.pay_links (
|
INSERT INTO lnurlp.pay_links (
|
||||||
|
|
@ -22,9 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
success_text,
|
success_text,
|
||||||
success_url,
|
success_url,
|
||||||
comment_chars,
|
comment_chars,
|
||||||
currency
|
currency,
|
||||||
|
fiat_base_multiplier
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
|
||||||
{returning}
|
{returning}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -37,6 +44,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
data.success_url,
|
data.success_url,
|
||||||
data.comment_chars,
|
data.comment_chars,
|
||||||
data.currency,
|
data.currency,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if db.type == SQLITE:
|
if db.type == SQLITE:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ async def api_lnurl_response(request: Request, link_id):
|
||||||
|
|
||||||
resp = LnurlPayResponse(
|
resp = LnurlPayResponse(
|
||||||
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
|
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
|
||||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
min_sendable=round(link.min * rate) * 1000,
|
||||||
max_sendable=round(link.max * rate) * 1000,
|
max_sendable=round(link.max * rate) * 1000,
|
||||||
metadata=link.lnurlpay_metadata,
|
metadata=link.lnurlpay_metadata,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,13 @@ async def m003_min_max_comment_fiat(db):
|
||||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
|
||||||
await db.execute("UPDATE lnurlp.pay_links SET max = min;")
|
await db.execute("UPDATE lnurlp.pay_links SET max = min;")
|
||||||
await db.execute("DROP TABLE lnurlp.invoices")
|
await db.execute("DROP TABLE lnurlp.invoices")
|
||||||
|
|
||||||
|
|
||||||
|
async def m004_fiat_base_multiplier(db):
|
||||||
|
"""
|
||||||
|
Store the multiplier for fiat prices. We store the price in cents and
|
||||||
|
remember to multiply by 100 when we use it to convert to Dollars.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,21 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
class CreatePayLinkData(BaseModel):
|
class CreatePayLinkData(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
min: int = Query(0.01, ge=0.01)
|
min: float = Query(1, ge=0.01)
|
||||||
max: int = Query(0.01, ge=0.01)
|
max: float = Query(1, ge=0.01)
|
||||||
currency: str = Query(None)
|
currency: str = Query(None)
|
||||||
comment_chars: int = Query(0, ge=0, lt=800)
|
comment_chars: int = Query(0, ge=0, lt=800)
|
||||||
webhook_url: str = Query(None)
|
webhook_url: str = Query(None)
|
||||||
success_text: str = Query(None)
|
success_text: str = Query(None)
|
||||||
success_url: str = Query(None)
|
success_url: str = Query(None)
|
||||||
|
fiat_base_multiplier: int = Query(100, ge=1)
|
||||||
|
|
||||||
|
|
||||||
class PayLink(BaseModel):
|
class PayLink(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
wallet: str
|
wallet: str
|
||||||
description: str
|
description: str
|
||||||
min: int
|
min: float
|
||||||
served_meta: int
|
served_meta: int
|
||||||
served_pr: int
|
served_pr: int
|
||||||
webhook_url: Optional[str]
|
webhook_url: Optional[str]
|
||||||
|
|
@ -32,11 +33,15 @@ class PayLink(BaseModel):
|
||||||
success_url: Optional[str]
|
success_url: Optional[str]
|
||||||
currency: Optional[str]
|
currency: Optional[str]
|
||||||
comment_chars: int
|
comment_chars: int
|
||||||
max: int
|
max: float
|
||||||
|
fiat_base_multiplier: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "PayLink":
|
def from_row(cls, row: Row) -> "PayLink":
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
|
if data["currency"] and data["fiat_base_multiplier"]:
|
||||||
|
data["min"] /= data["fiat_base_multiplier"]
|
||||||
|
data["max"] /= data["fiat_base_multiplier"]
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
def lnurl(self, req: Request) -> str:
|
def lnurl(self, req: Request) -> str:
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,14 @@ async def api_link_create_or_update(
|
||||||
link_id=None,
|
link_id=None,
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
):
|
):
|
||||||
|
|
||||||
if data.min > data.max:
|
if data.min > data.max:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.currency == None and (
|
if data.currency == None and (
|
||||||
round(data.min) != data.min or round(data.max) != data.max
|
round(data.min) != data.min or round(data.max) != data.max or data.min < 1
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
|
||||||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
||||||
counter = struct.pack(">Q", counter)
|
counter = struct.pack(">Q", counter)
|
||||||
mac = hmac.new(key, counter, digest).digest()
|
mac = hmac.new(key, counter, digest).digest()
|
||||||
offset = mac[-1] & 0x0f
|
offset = mac[-1] & 0x0F
|
||||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
|
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||||
return str(binary)[-digits:].zfill(digits)
|
return str(binary)[-digits:].zfill(digits)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,7 @@ async def api_paywall_delete(
|
||||||
|
|
||||||
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
|
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
|
||||||
async def api_paywall_create_invoice(
|
async def api_paywall_create_invoice(
|
||||||
data: CreatePaywallInvoice,
|
data: CreatePaywallInvoice, paywall_id: str = Query(None)
|
||||||
paywall_id: str = Query(None)
|
|
||||||
):
|
):
|
||||||
paywall = await get_paywall(paywall_id)
|
paywall = await get_paywall(paywall_id)
|
||||||
if data.amount < paywall.amount:
|
if data.amount < paywall.amount:
|
||||||
|
|
@ -78,7 +77,9 @@ async def api_paywall_create_invoice(
|
||||||
|
|
||||||
|
|
||||||
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
|
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
|
||||||
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)):
|
async def api_paywal_check_invoice(
|
||||||
|
data: CheckPaywallInvoice, paywall_id: str = Query(None)
|
||||||
|
):
|
||||||
paywall = await get_paywall(paywall_id)
|
paywall = await get_paywall(paywall_id)
|
||||||
payment_hash = data.payment_hash
|
payment_hash = data.payment_hash
|
||||||
if not paywall:
|
if not paywall:
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
label="lightning⚡"
|
label="lightning⚡"
|
||||||
>
|
>
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
bitcoin onchain payment method not available
|
bitcoin lightning payment method not available
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
label="onchain⛓️"
|
label="onchain⛓️"
|
||||||
>
|
>
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
bitcoin lightning payment method not available
|
bitcoin onchain payment method not available
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class Service(BaseModel):
|
||||||
onchain: Optional[str]
|
onchain: Optional[str]
|
||||||
servicename: str # Currently, this will just always be "Streamlabs"
|
servicename: str # Currently, this will just always be "Streamlabs"
|
||||||
authenticated: bool # Whether a token (see below) has been acquired yet
|
authenticated: bool # Whether a token (see below) has been acquired yet
|
||||||
token: Optional[int] # The token with which to authenticate requests
|
token: Optional[str] # The token with which to authenticate requests
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "Service":
|
def from_row(cls, row: Row) -> "Service":
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
donationDialog: {
|
donationDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
name: '',
|
name: null,
|
||||||
sats: '',
|
sats: '',
|
||||||
message: ''
|
message: ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
from lnbits.extensions.satspay.models import CreateCharge
|
||||||
from lnbits.extensions.streamalerts.models import (
|
from lnbits.extensions.streamalerts.models import (
|
||||||
CreateDonation,
|
CreateDonation,
|
||||||
CreateService,
|
CreateService,
|
||||||
|
|
@ -113,10 +114,10 @@ async def api_create_donation(data: CreateDonation, request: Request):
|
||||||
service_id = data.service
|
service_id = data.service
|
||||||
service = await get_service(service_id)
|
service = await get_service(service_id)
|
||||||
charge_details = await get_charge_details(service.id)
|
charge_details = await get_charge_details(service.id)
|
||||||
name = data.name
|
name = data.name if data.name else "Anonymous"
|
||||||
|
|
||||||
description = f"{sats} sats donation from {name} to {service.twitchuser}"
|
description = f"{sats} sats donation from {name} to {service.twitchuser}"
|
||||||
charge = await create_charge(
|
create_charge_data = CreateCharge(
|
||||||
amount=sats,
|
amount=sats,
|
||||||
completelink=f"https://twitch.tv/{service.twitchuser}",
|
completelink=f"https://twitch.tv/{service.twitchuser}",
|
||||||
completelinktext="Back to Stream!",
|
completelinktext="Back to Stream!",
|
||||||
|
|
@ -124,6 +125,7 @@ async def api_create_donation(data: CreateDonation, request: Request):
|
||||||
description=description,
|
description=description,
|
||||||
**charge_details,
|
**charge_details,
|
||||||
)
|
)
|
||||||
|
charge = await create_charge(user=charge_details["user"], data=create_charge_data)
|
||||||
await create_donation(
|
await create_donation(
|
||||||
id=charge.id,
|
id=charge.id,
|
||||||
wallet=service.wallet,
|
wallet=service.wallet,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import NamedTuple, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -26,7 +26,7 @@ class createTip(BaseModel):
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Tip(NamedTuple):
|
class Tip(BaseModel):
|
||||||
"""A Tip represents a single donation"""
|
"""A Tip represents a single donation"""
|
||||||
|
|
||||||
id: str # This ID always corresponds to a satspay charge ID
|
id: str # This ID always corresponds to a satspay charge ID
|
||||||
|
|
@ -55,7 +55,7 @@ class createTips(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class TipJar(NamedTuple):
|
class TipJar(BaseModel):
|
||||||
"""A TipJar represents a user's tip jar"""
|
"""A TipJar represents a user's tip jar"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@
|
||||||
'INR',
|
'INR',
|
||||||
'IQD',
|
'IQD',
|
||||||
'IRR',
|
'IRR',
|
||||||
|
'IRT',
|
||||||
'ISK',
|
'ISK',
|
||||||
'JEP',
|
'JEP',
|
||||||
'JMD',
|
'JMD',
|
||||||
|
|
|
||||||
|
|
@ -16,39 +16,95 @@
|
||||||
<div class="row justify-center full-width">
|
<div class="row justify-center full-width">
|
||||||
<div class="col-12 col-sm-8 col-md-6 col-lg-4">
|
<div class="col-12 col-sm-8 col-md-6 col-lg-4">
|
||||||
<div class="keypad q-pa-sm">
|
<div class="keypad q-pa-sm">
|
||||||
<q-btn unelevated @click="stack.push(1)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(1)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>1</q-btn
|
>1</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(2)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(2)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>2</q-btn
|
>2</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(3)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(3)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>3</q-btn
|
>3</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@click="stack = []"
|
@click="stack = []"
|
||||||
size="xl"
|
size="xl"
|
||||||
color="pink"
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
class="btn-cancel"
|
class="btn-cancel"
|
||||||
>C</q-btn
|
>C</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(4)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(4)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>4</q-btn
|
>4</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(5)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(5)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>5</q-btn
|
>5</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(6)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(6)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>6</q-btn
|
>6</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(7)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(7)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>7</q-btn
|
>7</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(8)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(8)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>8</q-btn
|
>8</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(9)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(9)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>9</q-btn
|
>9</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -56,7 +112,9 @@
|
||||||
:disabled="amount == 0"
|
:disabled="amount == 0"
|
||||||
@click="showInvoice()"
|
@click="showInvoice()"
|
||||||
size="xl"
|
size="xl"
|
||||||
color="green"
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
>OK</q-btn
|
>OK</q-btn
|
||||||
>
|
>
|
||||||
|
|
@ -64,17 +122,27 @@
|
||||||
unelevated
|
unelevated
|
||||||
@click="stack.splice(-1, 1)"
|
@click="stack.splice(-1, 1)"
|
||||||
size="xl"
|
size="xl"
|
||||||
color="grey-7"
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>DEL</q-btn
|
>DEL</q-btn
|
||||||
>
|
>
|
||||||
<q-btn unelevated @click="stack.push(0)" size="xl" color="grey-8"
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="stack.push(0)"
|
||||||
|
size="xl"
|
||||||
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>0</q-btn
|
>0</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@click="urlDialog.show = true"
|
@click="urlDialog.show = true"
|
||||||
size="xl"
|
size="xl"
|
||||||
color="grey-7"
|
:outline="!($q.dark.isActive)"
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
>#</q-btn
|
>#</q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,8 +208,8 @@
|
||||||
transition-show="fade"
|
transition-show="fade"
|
||||||
class="text-light-green"
|
class="text-light-green"
|
||||||
style="font-size: 40em"
|
style="font-size: 40em"
|
||||||
></q-icon
|
></q-icon>
|
||||||
></q-dialog>
|
</q-dialog>
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
{% endblock %} {% block styles %}
|
{% endblock %} {% block styles %}
|
||||||
|
|
@ -152,9 +220,11 @@
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
grid-template-rows: repeat(4, 1fr);
|
grid-template-rows: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keypad .btn {
|
.keypad .btn {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel,
|
.btn-cancel,
|
||||||
.btn-confirm {
|
.btn-confirm {
|
||||||
grid-row: auto/span 2;
|
grid-row: auto/span 2;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
extension allows the creation and management of users and wallets.
|
extension allows the creation and management of users and wallets.
|
||||||
<br />For example, a games developer may be developing a game that needs
|
<br />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
|
each user to have their own wallet, LNbits can be included in the
|
||||||
develpoers stack as the user and wallet manager.<br />
|
developers stack as the user and wallet manager.<br />
|
||||||
<small>
|
<small>
|
||||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
>
|
>
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code
|
<code
|
||||||
><span class="text-light-blue">GET</span>
|
><span class="text-light-blue">GET</span>
|
||||||
/usermanager/api/v1/wallets<wallet_id></code
|
/usermanager/api/v1/transactions/<wallet_id></code
|
||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
<code>{"X-Api-Key": <string>}</code>
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.base_url
|
>curl -X GET {{ request.base_url
|
||||||
}}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
}}usermanager/api/v1/transactions/<wallet_id> -H "X-Api-Key: {{
|
||||||
user.wallets[0].inkey }}"
|
user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/usermanager/api/v1/users',
|
'/usermanager/api/v1/users',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.users = response.data.map(function (obj) {
|
self.users = response.data.map(function (obj) {
|
||||||
|
|
@ -362,7 +362,7 @@
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'/usermanager/api/v1/users/' + userId,
|
'/usermanager/api/v1/users/' + userId,
|
||||||
self.g.user.wallets[0].inkey
|
self.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.users = _.reject(self.users, function (obj) {
|
self.users = _.reject(self.users, function (obj) {
|
||||||
|
|
@ -389,7 +389,7 @@
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/usermanager/api/v1/wallets',
|
'/usermanager/api/v1/wallets',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.wallets = response.data.map(function (obj) {
|
self.wallets = response.data.map(function (obj) {
|
||||||
|
|
@ -447,7 +447,7 @@
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'/usermanager/api/v1/wallets/' + userId,
|
'/usermanager/api/v1/wallets/' + userId,
|
||||||
self.g.user.wallets[0].inkey
|
self.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.wallets = _.reject(self.wallets, function (obj) {
|
self.wallets = _.reject(self.wallets, function (obj) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core import update_user_extension
|
from lnbits.core import update_user_extension
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
|
|
||||||
from . import usermanager_ext
|
from . import usermanager_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
|
|
@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet
|
||||||
|
|
||||||
|
|
||||||
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||||
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||||
user_id = wallet.wallet.user
|
user_id = wallet.wallet.user
|
||||||
return [user.dict() for user in await get_usermanager_users(user_id)]
|
return [user.dict() for user in await get_usermanager_users(user_id)]
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ async def api_usermanager_users_create(
|
||||||
|
|
||||||
@usermanager_ext.delete("/api/v1/users/{user_id}")
|
@usermanager_ext.delete("/api/v1/users/{user_id}")
|
||||||
async def api_usermanager_users_delete(
|
async def api_usermanager_users_delete(
|
||||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
user = await get_usermanager_user(user_id)
|
user = await get_usermanager_user(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -93,7 +93,7 @@ async def api_usermanager_wallets_create(
|
||||||
|
|
||||||
|
|
||||||
@usermanager_ext.get("/api/v1/wallets")
|
@usermanager_ext.get("/api/v1/wallets")
|
||||||
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||||
admin_id = wallet.wallet.user
|
admin_id = wallet.wallet.user
|
||||||
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
|
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions(
|
||||||
|
|
||||||
@usermanager_ext.get("/api/v1/wallets/{user_id}")
|
@usermanager_ext.get("/api/v1/wallets/{user_id}")
|
||||||
async def api_usermanager_users_wallets(
|
async def api_usermanager_users_wallets(
|
||||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
return [
|
return [
|
||||||
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
|
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
|
||||||
|
|
@ -116,7 +116,7 @@ async def api_usermanager_users_wallets(
|
||||||
|
|
||||||
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
|
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||||
async def api_usermanager_wallets_delete(
|
async def api_usermanager_wallets_delete(
|
||||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
get_wallet = await get_usermanager_wallet(wallet_id)
|
get_wallet = await get_usermanager_wallet(wallet_id)
|
||||||
if not get_wallet:
|
if not get_wallet:
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)
|
||||||
async def api_update_mempool(
|
async def api_update_mempool(
|
||||||
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
|
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
mempool = await update_mempool(endpoint, user=w.wallet.user)
|
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
|
||||||
return mempool.dict()
|
return mempool.dict()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ async def create_withdraw_link(
|
||||||
unique_hash,
|
unique_hash,
|
||||||
k1,
|
k1,
|
||||||
open_time,
|
open_time,
|
||||||
usescsv
|
usescsv,
|
||||||
|
webhook_url
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
link_id,
|
link_id,
|
||||||
|
|
@ -42,6 +43,7 @@ async def create_withdraw_link(
|
||||||
urlsafe_short_hash(),
|
urlsafe_short_hash(),
|
||||||
int(datetime.now().timestamp()) + data.wait_time,
|
int(datetime.now().timestamp()) + data.wait_time,
|
||||||
usescsv,
|
usescsv,
|
||||||
|
data.webhook_url
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
link = await get_withdraw_link(link_id, 0)
|
link = await get_withdraw_link(link_id, 0)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
import traceback
|
||||||
|
import httpx
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
@ -30,7 +33,9 @@ async def api_lnurl_response(request: Request, unique_hash):
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(detail="Withdraw is spent.")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||||
|
)
|
||||||
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||||
withdrawResponse = {
|
withdrawResponse = {
|
||||||
"tag": "withdrawRequest",
|
"tag": "withdrawRequest",
|
||||||
|
|
@ -48,7 +53,11 @@ async def api_lnurl_response(request: Request, unique_hash):
|
||||||
|
|
||||||
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
|
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
|
||||||
async def api_lnurl_callback(
|
async def api_lnurl_callback(
|
||||||
unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...)
|
unique_hash,
|
||||||
|
request: Request,
|
||||||
|
k1: str = Query(...),
|
||||||
|
pr: str = Query(...),
|
||||||
|
id_unique_hash=None,
|
||||||
):
|
):
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
@ -58,20 +67,37 @@ async def api_lnurl_callback(
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||||
|
)
|
||||||
|
|
||||||
if link.k1 != k1:
|
if link.k1 != k1:
|
||||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Bad request.")
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.")
|
||||||
|
|
||||||
if now < link.open_time:
|
if now < link.open_time:
|
||||||
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
|
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
|
||||||
|
|
||||||
try:
|
|
||||||
usescsv = ""
|
usescsv = ""
|
||||||
|
try:
|
||||||
for x in range(1, link.uses - link.used):
|
for x in range(1, link.uses - link.used):
|
||||||
usecv = link.usescsv.split(",")
|
usecv = link.usescsv.split(",")
|
||||||
usescsv += "," + str(usecv[x])
|
usescsv += "," + str(usecv[x])
|
||||||
usecsvback = usescsv
|
usecsvback = usescsv
|
||||||
|
|
||||||
|
found = False
|
||||||
|
if id_unique_hash is not None:
|
||||||
|
useslist = link.usescsv.split(",")
|
||||||
|
for ind, x in enumerate(useslist):
|
||||||
|
tohash = link.id + link.unique_hash + str(x)
|
||||||
|
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||||
|
found = True
|
||||||
|
useslist.pop(ind)
|
||||||
|
usescsv = ",".join(useslist)
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||||
|
)
|
||||||
|
else:
|
||||||
usescsv = usescsv[1:]
|
usescsv = usescsv[1:]
|
||||||
|
|
||||||
changesback = {
|
changesback = {
|
||||||
|
|
@ -89,16 +115,34 @@ async def api_lnurl_callback(
|
||||||
|
|
||||||
payment_request = pr
|
payment_request = pr
|
||||||
|
|
||||||
await pay_invoice(
|
payment_hash = await pay_invoice(
|
||||||
wallet_id=link.wallet,
|
wallet_id=link.wallet,
|
||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
max_sat=link.max_withdrawable,
|
max_sat=link.max_withdrawable,
|
||||||
extra={"tag": "withdraw"},
|
extra={"tag": "withdraw"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if link.webhook_url:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
link.webhook_url,
|
||||||
|
json={
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
"payment_request": payment_request,
|
||||||
|
"lnurlw": link.id,
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
|
||||||
|
print("Caught exception when dispatching webhook url:", exc)
|
||||||
|
|
||||||
return {"status": "OK"}
|
return {"status": "OK"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await update_withdraw_link(link.id, **changesback)
|
await update_withdraw_link(link.id, **changesback)
|
||||||
|
print(traceback.format_exc())
|
||||||
return {"status": "ERROR", "reason": "Link not working"}
|
return {"status": "ERROR", "reason": "Link not working"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,11 +159,13 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||||
|
)
|
||||||
|
|
||||||
useslist = link.usescsv.split(",")
|
useslist = link.usescsv.split(",")
|
||||||
found = False
|
found = False
|
||||||
|
|
@ -127,15 +173,16 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
|
||||||
tohash = link.id + link.unique_hash + str(x)
|
tohash = link.id + link.unique_hash + str(x)
|
||||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||||
found = True
|
found = True
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||||
)
|
)
|
||||||
|
|
||||||
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||||
withdrawResponse = {
|
withdrawResponse = {
|
||||||
"tag": "withdrawRequest",
|
"tag": "withdrawRequest",
|
||||||
"callback": url,
|
"callback": url + "?id_unique_hash=" + id_unique_hash,
|
||||||
"k1": link.k1,
|
"k1": link.k1,
|
||||||
"minWithdrawable": link.min_withdrawable * 1000,
|
"minWithdrawable": link.min_withdrawable * 1000,
|
||||||
"maxWithdrawable": link.max_withdrawable * 1000,
|
"maxWithdrawable": link.max_withdrawable * 1000,
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,9 @@ async def m003_make_hash_check(db):
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def m004_webhook_url(db):
|
||||||
|
"""
|
||||||
|
Adds webhook_url
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;")
|
||||||
|
|
@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel):
|
||||||
uses: int = Query(..., ge=1)
|
uses: int = Query(..., ge=1)
|
||||||
wait_time: int = Query(..., ge=1)
|
wait_time: int = Query(..., ge=1)
|
||||||
is_unique: bool
|
is_unique: bool
|
||||||
|
webhook_url: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
class WithdrawLink(BaseModel):
|
class WithdrawLink(BaseModel):
|
||||||
|
|
@ -32,6 +33,7 @@ class WithdrawLink(BaseModel):
|
||||||
used: int = Query(0)
|
used: int = Query(0)
|
||||||
usescsv: str = Query(None)
|
usescsv: str = Query(None)
|
||||||
number: int = Query(0)
|
number: int = Query(0)
|
||||||
|
webhook_url: str = Query(None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_spent(self) -> bool:
|
def is_spent(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,8 @@ new Vue({
|
||||||
'max_withdrawable',
|
'max_withdrawable',
|
||||||
'uses',
|
'uses',
|
||||||
'wait_time',
|
'wait_time',
|
||||||
'is_unique'
|
'is_unique',
|
||||||
|
'webhook_url'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@
|
||||||
<code
|
<code
|
||||||
>{"title": <string>, "min_withdrawable": <integer>,
|
>{"title": <string>, "min_withdrawable": <integer>,
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>}</code
|
"wait_time": <integer>, "is_unique": <boolean>,
|
||||||
|
"webhook_url": <string>}</code
|
||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 201 CREATED (application/json)
|
Returns 201 CREATED (application/json)
|
||||||
|
|
@ -81,7 +82,7 @@
|
||||||
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
|
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
|
||||||
<string>, "min_withdrawable": <integer>,
|
<string>, "min_withdrawable": <integer>,
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
"wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H
|
||||||
"Content-type: application/json" -H "X-Api-Key: {{
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
user.wallets[0].adminkey }}"
|
user.wallets[0].adminkey }}"
|
||||||
</code>
|
</code>
|
||||||
|
|
|
||||||
10
lnbits/extensions/withdraw/templates/withdraw/csv.html
Normal file
10
lnbits/extensions/withdraw/templates/withdraw/csv.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
data: function() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||||
%} {% block scripts %} {{ window_vars(user) }}
|
|
||||||
<script src="/withdraw/static/js/index.js"></script>
|
<script src="/withdraw/static/js/index.js"></script>
|
||||||
{% endblock %} {% block page %}
|
{% endblock %} {% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-btn unelevated color="primary" @click="simpleformDialog.show = true"
|
<q-btn unelevated color="primary" @click="simpleformDialog.show = true">Quick vouchers</q-btn>
|
||||||
>Quick vouchers</q-btn
|
<q-btn unelevated color="primary" @click="formDialog.show = true">Advanced withdraw link(s)</q-btn>
|
||||||
>
|
|
||||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
|
||||||
>Advanced withdraw link(s)</q-btn
|
|
||||||
>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
|
@ -25,14 +20,7 @@
|
||||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-table
|
<q-table dense flat :data="sortedWithdrawLinks" row-key="id" :columns="withdrawLinksTable.columns" :pagination.sync="withdrawLinksTable.pagination">
|
||||||
dense
|
|
||||||
flat
|
|
||||||
:data="sortedWithdrawLinks"
|
|
||||||
row-key="id"
|
|
||||||
:columns="withdrawLinksTable.columns"
|
|
||||||
:pagination.sync="withdrawLinksTable.pagination"
|
|
||||||
>
|
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
|
|
@ -41,6 +29,7 @@
|
||||||
{{ col.label }}
|
{{ col.label }}
|
||||||
</q-th>
|
</q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
|
|
@ -69,6 +58,17 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
><q-tooltip> embeddable image </q-tooltip></q-btn
|
><q-tooltip> embeddable image </q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="reorder"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/withdraw/csv/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
><q-tooltip> csv list </q-tooltip></q-btn
|
||||||
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
dense
|
dense
|
||||||
|
|
@ -82,6 +82,11 @@
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
{{ col.value }}
|
{{ col.value }}
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||||
|
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
|
|
@ -101,8 +106,7 @@
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template> {% endraw %}
|
||||||
{% endraw %}
|
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -129,101 +133,45 @@
|
||||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||||
<q-select
|
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="formDialog.data.wallet"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
</q-select>
|
||||||
<q-input
|
<q-input filled dense v-model.trim="formDialog.data.title" type="text" label="Link title *"></q-input>
|
||||||
filled
|
<q-input filled dense v-model.number="formDialog.data.min_withdrawable" type="number" min="10" label="Min withdrawable (sat, at least 10) *"></q-input>
|
||||||
dense
|
<q-input filled dense v-model.number="formDialog.data.max_withdrawable" type="number" min="10" label="Max withdrawable (sat, at least 10) *"></q-input>
|
||||||
v-model.trim="formDialog.data.title"
|
<q-input filled dense v-model.number="formDialog.data.uses" type="number" max="250" :default="1" label="Amount of uses *"></q-input>
|
||||||
type="text"
|
|
||||||
label="Link title *"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.min_withdrawable"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
label="Min withdrawable (sat, at least 10) *"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.max_withdrawable"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
label="Max withdrawable (sat, at least 10) *"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.uses"
|
|
||||||
type="number"
|
|
||||||
:default="1"
|
|
||||||
label="Amount of uses *"
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-col-gutter-none">
|
<div class="row q-col-gutter-none">
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-input
|
<q-input filled dense v-model.number="formDialog.data.wait_time" type="number" :default="1" label="Time between withdrawals *">
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.wait_time"
|
|
||||||
type="number"
|
|
||||||
:default="1"
|
|
||||||
label="Time between withdrawals *"
|
|
||||||
>
|
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4 q-pl-xs">
|
<div class="col-4 q-pl-xs">
|
||||||
<q-select
|
<q-select filled dense v-model="formDialog.secondMultiplier" :options="formDialog.secondMultiplierOptions">
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.secondMultiplier"
|
|
||||||
:options="formDialog.secondMultiplierOptions"
|
|
||||||
>
|
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.webhook_url"
|
||||||
|
type="text"
|
||||||
|
label="Webhook URL (optional)"
|
||||||
|
hint="A URL to be called whenever this link gets used."
|
||||||
|
></q-input>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item tag="label" class="rounded-borders">
|
<q-item tag="label" class="rounded-borders">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-checkbox
|
<q-checkbox v-model="formDialog.data.is_unique" color="primary"></q-checkbox>
|
||||||
v-model="formDialog.data.is_unique"
|
|
||||||
color="primary"
|
|
||||||
></q-checkbox>
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label
|
<q-item-label>Use unique withdraw QR codes to reduce `assmilking`
|
||||||
>Use unique withdraw QR codes to reduce
|
</q-item-label>
|
||||||
`assmilking`</q-item-label
|
<q-item-label caption>This is recommended if you are sharing the links on social media or print QR codes.</q-item-label>
|
||||||
>
|
|
||||||
<q-item-label caption
|
|
||||||
>This is recommended if you are sharing the links on social
|
|
||||||
media or print QR codes.</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn v-if="formDialog.data.id" unelevated color="primary" type="submit">Update withdraw link</q-btn>
|
||||||
v-if="formDialog.data.id"
|
<q-btn v-else unelevated color="primary" :disable="
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
>Update withdraw link</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
|
||||||
v-else
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="
|
|
||||||
formDialog.data.wallet == null ||
|
formDialog.data.wallet == null ||
|
||||||
formDialog.data.title == null ||
|
formDialog.data.title == null ||
|
||||||
(formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) ||
|
(formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) ||
|
||||||
|
|
@ -233,67 +181,29 @@
|
||||||
formDialog.data.max_withdrawable < formDialog.data.min_withdrawable
|
formDialog.data.max_withdrawable < formDialog.data.min_withdrawable
|
||||||
) ||
|
) ||
|
||||||
formDialog.data.uses == null ||
|
formDialog.data.uses == null ||
|
||||||
formDialog.data.wait_time == null"
|
formDialog.data.wait_time == null" type="submit">Create withdraw link</q-btn>
|
||||||
type="submit"
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
>Create withdraw link</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog
|
<q-dialog v-model="simpleformDialog.show" position="top" @hide="simplecloseFormDialog">
|
||||||
v-model="simpleformDialog.show"
|
|
||||||
position="top"
|
|
||||||
@hide="simplecloseFormDialog"
|
|
||||||
>
|
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<q-form @submit="simplesendFormData" class="q-gutter-md">
|
<q-form @submit="simplesendFormData" class="q-gutter-md">
|
||||||
<q-select
|
<q-select filled dense emit-value v-model="simpleformDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="simpleformDialog.data.wallet"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
</q-select>
|
||||||
<q-input
|
<q-input filled dense v-model.number="simpleformDialog.data.max_withdrawable" type="number" min="10" label="Withdraw amount per voucher (sat, at least 10)"></q-input>
|
||||||
filled
|
<q-input filled dense v-model.number="simpleformDialog.data.uses" type="number" max="250" :default="1" label="Number of vouchers"></q-input>
|
||||||
dense
|
|
||||||
v-model.number="simpleformDialog.data.max_withdrawable"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
label="Withdraw amount per voucher (sat, at least 10)"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="simpleformDialog.data.uses"
|
|
||||||
type="number"
|
|
||||||
:default="1"
|
|
||||||
label="Number of vouchers"
|
|
||||||
></q-input>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn unelevated color="primary" :disable="
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="
|
|
||||||
simpleformDialog.data.wallet == null ||
|
simpleformDialog.data.wallet == null ||
|
||||||
|
|
||||||
simpleformDialog.data.max_withdrawable == null ||
|
simpleformDialog.data.max_withdrawable == null ||
|
||||||
simpleformDialog.data.max_withdrawable < 1 ||
|
simpleformDialog.data.max_withdrawable < 1 ||
|
||||||
simpleformDialog.data.uses == null"
|
simpleformDialog.data.uses == null" type="submit">Create vouchers</q-btn>
|
||||||
type="submit"
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
>Create vouchers</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -302,19 +212,12 @@
|
||||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
<qrcode
|
<qrcode :value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :options="{width: 800}" class="rounded-borders"></qrcode>
|
||||||
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
|
|
||||||
:options="{width: 800}"
|
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
{% raw %}
|
{% raw %}
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
<p style="word-break: break-all">
|
<p style="word-break: break-all">
|
||||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||||
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span
|
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
|
||||||
v-if="qrCodeDialog.data.is_unique"
|
|
||||||
class="text-deep-purple"
|
|
||||||
>
|
|
||||||
(QR code will change after each withdrawal)</span
|
(QR code will change after each withdrawal)</span
|
||||||
><br />
|
><br />
|
||||||
<strong>Max. withdrawable:</strong> {{
|
<strong>Max. withdrawable:</strong> {{
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,38 @@ async def print_qr(request: Request, link_id):
|
||||||
return withdraw_renderer().TemplateResponse(
|
return withdraw_renderer().TemplateResponse(
|
||||||
"withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
|
"withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
|
||||||
|
async def print_qr(request: Request, link_id):
|
||||||
|
link = await get_withdraw_link(link_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
|
)
|
||||||
|
# response.status_code = HTTPStatus.NOT_FOUND
|
||||||
|
# return "Withdraw link does not exist."
|
||||||
|
|
||||||
|
if link.uses == 0:
|
||||||
|
|
||||||
|
return withdraw_renderer().TemplateResponse(
|
||||||
|
"withdraw/csv.html",
|
||||||
|
{"request": request, "link": link.dict(), "unique": False},
|
||||||
|
)
|
||||||
|
links = []
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for x in link.usescsv.split(","):
|
||||||
|
linkk = await get_withdraw_link(link_id, count)
|
||||||
|
if not linkk:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
|
)
|
||||||
|
links.append(str(linkk.lnurl(request)))
|
||||||
|
count = count + 1
|
||||||
|
page_link = list(chunks(links, 2))
|
||||||
|
linked = list(chunks(page_link, 5))
|
||||||
|
|
||||||
|
return withdraw_renderer().TemplateResponse(
|
||||||
|
"withdraw/csv.html", {"request": request, "link": linked, "unique": True}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ async def api_link_create_or_update(
|
||||||
link_id: str = None,
|
link_id: str = None,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
):
|
||||||
|
if data.uses > 250:
|
||||||
|
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
if data.min_withdrawable < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
if data.max_withdrawable < data.min_withdrawable:
|
if data.max_withdrawable < data.min_withdrawable:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
|
detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ from typing import Any, List, NamedTuple, Optional
|
||||||
import jinja2
|
import jinja2
|
||||||
import shortuuid # type: ignore
|
import shortuuid # type: ignore
|
||||||
|
|
||||||
|
import lnbits.settings as settings
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
|
|
||||||
import lnbits.settings as settings
|
|
||||||
|
|
||||||
|
|
||||||
class Extension(NamedTuple):
|
class Extension(NamedTuple):
|
||||||
code: str
|
code: str
|
||||||
|
|
@ -26,7 +25,9 @@ class Extension(NamedTuple):
|
||||||
class ExtensionManager:
|
class ExtensionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
|
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
|
||||||
self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS]
|
self._admin_only: List[str] = [
|
||||||
|
x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS
|
||||||
|
]
|
||||||
self._extension_folders: List[str] = [
|
self._extension_folders: List[str] = [
|
||||||
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
|
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
|
||||||
][0]
|
][0]
|
||||||
|
|
@ -160,6 +161,10 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
||||||
["lnbits/templates", "lnbits/core/templates", *additional_folders]
|
["lnbits/templates", "lnbits/core/templates", *additional_folders]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.LNBITS_AD_SPACE:
|
||||||
|
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
|
||||||
|
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
|
||||||
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
||||||
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
|
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
|
||||||
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
|
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
|
||||||
|
|
@ -167,6 +172,8 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
||||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
|
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
|
||||||
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
|
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
|
||||||
t.env.globals["EXTENSIONS"] = get_valid_extensions()
|
t.env.globals["EXTENSIONS"] = get_valid_extensions()
|
||||||
|
if settings.LNBITS_CUSTOM_LOGO:
|
||||||
|
t.env.globals["USE_CUSTOM_LOGO"] = settings.LNBITS_CUSTOM_LOGO
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
|
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import subprocess
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import subprocess
|
||||||
from environs import Env # type: ignore
|
|
||||||
from os import path
|
from os import path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from environs import Env # type: ignore
|
||||||
|
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
|
|
@ -29,11 +28,15 @@ LNBITS_ALLOWED_USERS: List[str] = env.list(
|
||||||
"LNBITS_ALLOWED_USERS", default=[], subcast=str
|
"LNBITS_ALLOWED_USERS", default=[], subcast=str
|
||||||
)
|
)
|
||||||
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
|
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
|
||||||
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
|
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list(
|
||||||
|
"LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str
|
||||||
|
)
|
||||||
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
|
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
|
||||||
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
|
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LNBITS_AD_SPACE = env.list("LNBITS_AD_SPACE", default=[])
|
||||||
|
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
|
||||||
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
||||||
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
|
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
|
||||||
LNBITS_SITE_TAGLINE = env.str(
|
LNBITS_SITE_TAGLINE = env.str(
|
||||||
|
|
@ -45,6 +48,7 @@ LNBITS_THEME_OPTIONS: List[str] = env.list(
|
||||||
default="classic, flamingo, mint, salvador, monochrome, autumn",
|
default="classic, flamingo, mint, salvador, monochrome, autumn",
|
||||||
subcast=str,
|
subcast=str,
|
||||||
)
|
)
|
||||||
|
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
|
||||||
|
|
||||||
WALLET = wallet_class()
|
WALLET = wallet_class()
|
||||||
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
||||||
|
|
|
||||||
BIN
lnbits/static/images/templatead.png
Normal file
BIN
lnbits/static/images/templatead.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
|
|
@ -1,63 +1,14 @@
|
||||||
$themes: (
|
$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255)));
|
||||||
'classic': (
|
@each $theme,
|
||||||
primary: #673ab7,
|
$colors in $themes {
|
||||||
secondary: #9c27b0,
|
@each $name,
|
||||||
dark: #1f2234,
|
$color in $colors {
|
||||||
info: #333646,
|
|
||||||
marginal-bg: #1f2234,
|
|
||||||
marginal-text: #fff
|
|
||||||
),
|
|
||||||
'bitcoin': (
|
|
||||||
primary: #ff9853,
|
|
||||||
secondary: #ff7353,
|
|
||||||
dark: #2d293b,
|
|
||||||
info: #333646,
|
|
||||||
marginal-bg: #2d293b,
|
|
||||||
marginal-text: #fff
|
|
||||||
),
|
|
||||||
'mint': (
|
|
||||||
primary: #3ab77d,
|
|
||||||
secondary: #27b065,
|
|
||||||
dark: #1f342b,
|
|
||||||
info: #334642,
|
|
||||||
marginal-bg: #1f342b,
|
|
||||||
marginal-text: #fff
|
|
||||||
),
|
|
||||||
'autumn': (
|
|
||||||
primary: #b7763a,
|
|
||||||
secondary: #b07927,
|
|
||||||
dark: #34291f,
|
|
||||||
info: #463f33,
|
|
||||||
marginal-bg: #342a1f,
|
|
||||||
marginal-text: rgb(255, 255, 255)
|
|
||||||
),
|
|
||||||
'flamingo': (
|
|
||||||
primary: #d11d53,
|
|
||||||
secondary: #db3e6d,
|
|
||||||
dark: #803a45,
|
|
||||||
info: #ec7599,
|
|
||||||
marginal-bg: #803a45,
|
|
||||||
marginal-text: rgb(255, 255, 255)
|
|
||||||
),
|
|
||||||
'monochrome': (
|
|
||||||
primary: #494949,
|
|
||||||
secondary: #6b6b6b,
|
|
||||||
dark: #000,
|
|
||||||
info: rgb(39, 39, 39),
|
|
||||||
marginal-bg: #000,
|
|
||||||
marginal-text: rgb(255, 255, 255)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
@each $theme, $colors in $themes {
|
|
||||||
@each $name, $color in $colors {
|
|
||||||
@if $name=='dark' {
|
@if $name=='dark' {
|
||||||
[data-theme='#{$theme}'] .q-drawer--dark,
|
[data-theme='#{$theme}'] .q-drawer--dark,
|
||||||
body[data-theme='#{$theme}'].body--dark,
|
body[data-theme='#{$theme}'].body--dark,
|
||||||
[data-theme='#{$theme}'] .q-menu--dark {
|
[data-theme='#{$theme}'] .q-menu--dark {
|
||||||
background: $color !important;
|
background: $color !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||||
// set a darker body bg for all themes, when in "dark mode"
|
// set a darker body bg for all themes, when in "dark mode"
|
||||||
body[data-theme='#{$theme}'].body--dark {
|
body[data-theme='#{$theme}'].body--dark {
|
||||||
|
|
@ -73,7 +24,8 @@ $themes: (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[data-theme='#{$theme}'] {
|
[data-theme='#{$theme}'] {
|
||||||
@each $name, $color in $colors {
|
@each $name,
|
||||||
|
$color in $colors {
|
||||||
.bg-#{$name} {
|
.bg-#{$name} {
|
||||||
background: $color !important;
|
background: $color !important;
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +35,15 @@ $themes: (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='freedom'] .q-drawer--dark {
|
||||||
|
background: #0a0a0a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='freedom'] .q-header {
|
||||||
|
background: #0a0a0a !important;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme='salvador'] .q-drawer--dark {
|
[data-theme='salvador'] .q-drawer--dark {
|
||||||
background: #242424 !important;
|
background: #242424 !important;
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +80,6 @@ body.body--dark .q-field--error {
|
||||||
padding-bottom: 5px !important;
|
padding-bottom: 5px !important;
|
||||||
border-top-right-radius: 3px;
|
border-top-right-radius: 3px;
|
||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
|
|
||||||
&.q-item--active {
|
&.q-item--active {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
@ -136,7 +96,6 @@ body.body--dark .q-field--error {
|
||||||
.q-table__bottom {
|
.q-table__bottom {
|
||||||
padding-left: 6px !important;
|
padding-left: 6px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:last-child,
|
th:last-child,
|
||||||
td:last-child,
|
td:last-child,
|
||||||
.q-table__bottom {
|
.q-table__bottom {
|
||||||
|
|
@ -150,13 +109,11 @@ a.inherit {
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR video
|
// QR video
|
||||||
|
|
||||||
video {
|
video {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Material icons font
|
// Material icons font
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ async def perform_balance_checks():
|
||||||
|
|
||||||
|
|
||||||
async def invoice_callback_dispatcher(checking_id: str):
|
async def invoice_callback_dispatcher(checking_id: str):
|
||||||
payment = await get_standalone_payment(checking_id)
|
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||||
if payment and payment.is_in:
|
if payment and payment.is_in:
|
||||||
await payment.set_pending(False)
|
await payment.set_pending(False)
|
||||||
for send_chan in invoice_listeners:
|
for send_chan in invoice_listeners:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!---->
|
<!---->
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
|
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
|
||||||
|
|
||||||
{% block styles %}{% endblock %}
|
{% block styles %}{% endblock %}
|
||||||
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|
@ -35,10 +34,12 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<q-toolbar-title>
|
<q-toolbar-title>
|
||||||
<q-btn flat no-caps dense size="lg" type="a" href="/">
|
<q-btn flat no-caps dense size="lg" type="a" href="/">
|
||||||
{% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
|
{% block toolbar_title %} {% if USE_CUSTOM_LOGO %}
|
||||||
SITE_TITLE }} {% else %} <strong>LN</strong>bits {% endif %} {%
|
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
||||||
endblock %}</q-btn
|
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
|
||||||
>
|
%}
|
||||||
|
<strong>LN</strong>bits {% endif %} {%endif%} {% endblock %}
|
||||||
|
</q-btn>
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
{% block beta %}
|
{% block beta %}
|
||||||
<q-badge color="yellow" text-color="black" class="q-mr-md">
|
<q-badge color="yellow" text-color="black" class="q-mr-md">
|
||||||
|
|
@ -118,6 +119,16 @@
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>elSalvador</q-tooltip>
|
><q-tooltip>elSalvador</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('freedom')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('freedom')"
|
||||||
|
icon="format_color_fill"
|
||||||
|
color="pink-13"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>Freedom</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="g.allowedThemes.includes('flamingo')"
|
v-if="g.allowedThemes.includes('flamingo')"
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
|
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
|
||||||
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
|
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
|
||||||
<a href="/" class="inherit">
|
<a
|
||||||
{% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
|
href="/"
|
||||||
<strong>LN</strong>bits {% endif %}
|
class="inherit q-btn q-btn-item non-selectable no-outline q-btn--flat q-btn--rectangle q-btn--actionable q-focusable q-hoverable q-btn--no-uppercase q-btn--wrap q-btn--dense q-btn--active"
|
||||||
|
style="font-size: 20px"
|
||||||
|
>
|
||||||
|
{% if USE_CUSTOM_LOGO %}
|
||||||
|
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
||||||
|
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
|
||||||
|
<strong>LN</strong>bits {% endif %} {% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ currencies = {
|
||||||
"IMP": "Isle of Man Pound",
|
"IMP": "Isle of Man Pound",
|
||||||
"INR": "Indian Rupee",
|
"INR": "Indian Rupee",
|
||||||
"IQD": "Iraqi Dinar",
|
"IQD": "Iraqi Dinar",
|
||||||
|
"IRT": "Iranian Toman",
|
||||||
"ISK": "Icelandic Króna",
|
"ISK": "Icelandic Króna",
|
||||||
"JEP": "Jersey Pound",
|
"JEP": "Jersey Pound",
|
||||||
"JMD": "Jamaican Dollar",
|
"JMD": "Jamaican Dollar",
|
||||||
|
|
@ -179,6 +180,12 @@ class Provider(NamedTuple):
|
||||||
|
|
||||||
|
|
||||||
exchange_rate_providers = {
|
exchange_rate_providers = {
|
||||||
|
"exir": Provider(
|
||||||
|
"Exir",
|
||||||
|
"exir.io",
|
||||||
|
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
|
||||||
|
lambda data, replacements: data["last"],
|
||||||
|
),
|
||||||
"bitfinex": Provider(
|
"bitfinex": Provider(
|
||||||
"Bitfinex",
|
"Bitfinex",
|
||||||
"bitfinex.com",
|
"bitfinex.com",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# flake8: noqa
|
# flake8: noqa
|
||||||
|
|
||||||
from .void import VoidWallet
|
|
||||||
from .clightning import CLightningWallet
|
from .clightning import CLightningWallet
|
||||||
from .lndgrpc import LndWallet
|
from .eclair import EclairWallet
|
||||||
from .lntxbot import LntxbotWallet
|
from .fake import FakeWallet
|
||||||
from .opennode import OpenNodeWallet
|
|
||||||
from .lnpay import LNPayWallet
|
|
||||||
from .lnbits import LNbitsWallet
|
from .lnbits import LNbitsWallet
|
||||||
from .lndrest import LndRestWallet
|
from .lndrest import LndRestWallet
|
||||||
|
from .lnpay import LNPayWallet
|
||||||
|
from .lntxbot import LntxbotWallet
|
||||||
|
from .opennode import OpenNodeWallet
|
||||||
from .spark import SparkWallet
|
from .spark import SparkWallet
|
||||||
from .fake import FakeWallet
|
from .void import VoidWallet
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ class Wallet(ABC):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def pay_invoice(self, bolt11: str) -> Coroutine[None, None, PaymentResponse]:
|
def pay_invoice(
|
||||||
|
self, bolt11: str, fee_limit_msat: int
|
||||||
|
) -> Coroutine[None, None, PaymentResponse]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from .base import (
|
||||||
Unsupported,
|
Unsupported,
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
|
from lnbits import bolt11 as lnbits_bolt11
|
||||||
|
|
||||||
|
|
||||||
def async_wrap(func):
|
def async_wrap(func):
|
||||||
|
|
@ -31,8 +32,8 @@ def async_wrap(func):
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
def _pay_invoice(ln, bolt11):
|
def _pay_invoice(ln, payload):
|
||||||
return ln.pay(bolt11)
|
return ln.call("pay", payload)
|
||||||
|
|
||||||
|
|
||||||
def _paid_invoices_stream(ln, last_pay_index):
|
def _paid_invoices_stream(ln, last_pay_index):
|
||||||
|
|
@ -102,10 +103,18 @@ class CLightningWallet(Wallet):
|
||||||
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
|
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
|
||||||
return InvoiceResponse(False, label, None, error_message)
|
return InvoiceResponse(False, label, None, error_message)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
|
invoice = lnbits_bolt11.decode(bolt11)
|
||||||
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"bolt11": bolt11,
|
||||||
|
"maxfeepercent": "{:.11}".format(fee_limit_percent),
|
||||||
|
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee)
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
wrapped = async_wrap(_pay_invoice)
|
wrapped = async_wrap(_pay_invoice)
|
||||||
r = await wrapped(self.ln, bolt11)
|
r = await wrapped(self.ln, payload)
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
return PaymentResponse(False, None, 0, None, str(exc))
|
return PaymentResponse(False, None, 0, None, str(exc))
|
||||||
|
|
||||||
|
|
|
||||||
200
lnbits/wallets/eclair.py
Normal file
200
lnbits/wallets/eclair.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
from os import getenv
|
||||||
|
from typing import AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from websockets import connect
|
||||||
|
from websockets.exceptions import (
|
||||||
|
ConnectionClosed,
|
||||||
|
ConnectionClosedError,
|
||||||
|
ConnectionClosedOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
InvoiceResponse,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
StatusResponse,
|
||||||
|
Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EclairError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EclairWallet(Wallet):
|
||||||
|
def __init__(self):
|
||||||
|
url = getenv("ECLAIR_URL")
|
||||||
|
self.url = url[:-1] if url.endswith("/") else url
|
||||||
|
|
||||||
|
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
|
||||||
|
|
||||||
|
passw = getenv("ECLAIR_PASS")
|
||||||
|
encodedAuth = base64.b64encode(f":{passw}".encode("utf-8"))
|
||||||
|
auth = str(encodedAuth, "utf-8")
|
||||||
|
self.auth = {"Authorization": f"Basic {auth}"}
|
||||||
|
|
||||||
|
async def status(self) -> StatusResponse:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.url}/usablebalances", headers=self.auth, timeout=40
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except:
|
||||||
|
return StatusResponse(
|
||||||
|
f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.is_error:
|
||||||
|
return StatusResponse(data["error"], 0)
|
||||||
|
|
||||||
|
return StatusResponse(None, data[0]["canSend"] * 1000)
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
memo: Optional[str] = None,
|
||||||
|
description_hash: Optional[bytes] = None,
|
||||||
|
) -> InvoiceResponse:
|
||||||
|
|
||||||
|
data: Dict = {"amountMsat": amount * 1000}
|
||||||
|
if description_hash:
|
||||||
|
data["description_hash"] = description_hash.hex()
|
||||||
|
else:
|
||||||
|
data["description"] = memo or ""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.is_error:
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except:
|
||||||
|
error_message = r.text
|
||||||
|
pass
|
||||||
|
|
||||||
|
return InvoiceResponse(False, None, None, error_message)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
|
||||||
|
|
||||||
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.url}/payinvoice",
|
||||||
|
headers=self.auth,
|
||||||
|
data={"invoice": bolt11, "blocking": True},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in r.json():
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except:
|
||||||
|
error_message = r.text
|
||||||
|
pass
|
||||||
|
return PaymentResponse(False, None, 0, None, error_message)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
checking_id = data["paymentHash"]
|
||||||
|
preimage = data["paymentPreimage"]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.url}/getsentinfo",
|
||||||
|
headers=self.auth,
|
||||||
|
data={"paymentHash": checking_id},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in r.json():
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except:
|
||||||
|
error_message = r.text
|
||||||
|
pass
|
||||||
|
return PaymentResponse(
|
||||||
|
True, checking_id, 0, preimage, error_message
|
||||||
|
) ## ?? is this ok ??
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
fees = [i["status"] for i in data]
|
||||||
|
fee_msat = sum([i["feesPaid"] for i in fees])
|
||||||
|
|
||||||
|
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.url}/getreceivedinfo",
|
||||||
|
headers=self.auth,
|
||||||
|
data={"paymentHash": checking_id},
|
||||||
|
)
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
if r.is_error or "error" in data:
|
||||||
|
return PaymentStatus(None)
|
||||||
|
|
||||||
|
if data["status"]["type"] != "received":
|
||||||
|
return PaymentStatus(False)
|
||||||
|
|
||||||
|
return PaymentStatus(True)
|
||||||
|
|
||||||
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
url=f"{self.url}/getsentinfo",
|
||||||
|
headers=self.auth,
|
||||||
|
data={"paymentHash": checking_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = r.json()[0]
|
||||||
|
|
||||||
|
if r.is_error:
|
||||||
|
return PaymentStatus(None)
|
||||||
|
|
||||||
|
if data["status"]["type"] != "sent":
|
||||||
|
return PaymentStatus(False)
|
||||||
|
|
||||||
|
return PaymentStatus(True)
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with connect(
|
||||||
|
self.ws_url,
|
||||||
|
extra_headers=[("Authorization", self.auth["Authorization"])],
|
||||||
|
) as ws:
|
||||||
|
while True:
|
||||||
|
message = await ws.recv()
|
||||||
|
message = json.loads(message)
|
||||||
|
|
||||||
|
if message and message["type"] == "payment-received":
|
||||||
|
yield message["paymentHash"]
|
||||||
|
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ConnectionClosedOK,
|
||||||
|
ConnectionClosedError,
|
||||||
|
ConnectionClosed,
|
||||||
|
) as ose:
|
||||||
|
print("OSE", ose)
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("lost connection to eclair's websocket, retrying in 5 seconds")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
@ -36,7 +36,13 @@ class FakeWallet(Wallet):
|
||||||
"out": False,
|
"out": False,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"currency": "bc",
|
"currency": "bc",
|
||||||
"privkey": hashlib.pbkdf2_hmac('sha256', secret.encode("utf-8"), ("FakeWallet").encode("utf-8"), 2048, 32).hex(),
|
"privkey": hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
secret.encode("utf-8"),
|
||||||
|
("FakeWallet").encode("utf-8"),
|
||||||
|
2048,
|
||||||
|
32,
|
||||||
|
).hex(),
|
||||||
"memo": None,
|
"memo": None,
|
||||||
"description_hash": None,
|
"description_hash": None,
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|
@ -53,22 +59,29 @@ class FakeWallet(Wallet):
|
||||||
data["tags_set"] = ["d"]
|
data["tags_set"] = ["d"]
|
||||||
data["memo"] = memo
|
data["memo"] = memo
|
||||||
data["description"] = memo
|
data["description"] = memo
|
||||||
randomHash = data["privkey"][:6] + hashlib.sha256(
|
randomHash = (
|
||||||
str(random.getrandbits(256)).encode("utf-8")
|
data["privkey"][:6]
|
||||||
).hexdigest()[6:]
|
+ hashlib.sha256(str(random.getrandbits(256)).encode("utf-8")).hexdigest()[
|
||||||
|
6:
|
||||||
|
]
|
||||||
|
)
|
||||||
data["paymenthash"] = randomHash
|
data["paymenthash"] = randomHash
|
||||||
payment_request = encode(data)
|
payment_request = encode(data)
|
||||||
checking_id = randomHash
|
checking_id = randomHash
|
||||||
|
|
||||||
return InvoiceResponse(True, checking_id, payment_request)
|
return InvoiceResponse(True, checking_id, payment_request)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
invoice = decode(bolt11)
|
invoice = decode(bolt11)
|
||||||
if hasattr(invoice, 'checking_id') and invoice.checking_id[6:] == data["privkey"][:6]:
|
if (
|
||||||
|
hasattr(invoice, "checking_id")
|
||||||
|
and invoice.checking_id[6:] == data["privkey"][:6]
|
||||||
|
):
|
||||||
return PaymentResponse(True, invoice.payment_hash, 0)
|
return PaymentResponse(True, invoice.payment_hash, 0)
|
||||||
else:
|
else:
|
||||||
return PaymentResponse(ok = False, error_message="Only internal invoices can be used!")
|
return PaymentResponse(
|
||||||
|
ok=False, error_message="Only internal invoices can be used!"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ class LNbitsWallet(Wallet):
|
||||||
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
url=f"{self.endpoint}/api/v1/payments",
|
url=f"{self.endpoint}/api/v1/payments",
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,11 @@ class LndWallet(Wallet):
|
||||||
or getenv("LND_INVOICE_MACAROON")
|
or getenv("LND_INVOICE_MACAROON")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
|
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
|
||||||
if encrypted_macaroon:
|
if encrypted_macaroon:
|
||||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||||
|
encrypted_macaroon
|
||||||
|
)
|
||||||
self.macaroon = load_macaroon(macaroon)
|
self.macaroon = load_macaroon(macaroon)
|
||||||
|
|
||||||
cert = open(self.cert_path, "rb").read()
|
cert = open(self.cert_path, "rb").read()
|
||||||
|
|
@ -143,10 +144,10 @@ class LndWallet(Wallet):
|
||||||
payment_request = str(resp.payment_request)
|
payment_request = str(resp.payment_request)
|
||||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
resp = await self.rpc.SendPayment(
|
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||||
lnrpc.SendPaymentRequest(payment_request=bolt11)
|
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
|
||||||
)
|
resp = await self.rpc.SendPaymentSync(req)
|
||||||
|
|
||||||
if resp.payment_error:
|
if resp.payment_error:
|
||||||
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ class LndRestWallet(Wallet):
|
||||||
|
|
||||||
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
|
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
|
||||||
if encrypted_macaroon:
|
if encrypted_macaroon:
|
||||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||||
|
encrypted_macaroon
|
||||||
|
)
|
||||||
self.macaroon = load_macaroon(macaroon)
|
self.macaroon = load_macaroon(macaroon)
|
||||||
|
|
||||||
self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
|
self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
|
||||||
self.cert = getenv("LND_REST_CERT")
|
self.cert = getenv("LND_REST_CERT", True)
|
||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
try:
|
try:
|
||||||
|
|
@ -97,15 +99,11 @@ class LndRestWallet(Wallet):
|
||||||
|
|
||||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient(verify=self.cert) as client:
|
async with httpx.AsyncClient(verify=self.cert) as client:
|
||||||
# set the fee limit for the payment
|
# set the fee limit for the payment
|
||||||
invoice = lnbits_bolt11.decode(bolt11)
|
|
||||||
lnrpcFeeLimit = dict()
|
lnrpcFeeLimit = dict()
|
||||||
if invoice.amount_msat > 1000_000:
|
lnrpcFeeLimit["fixed_msat"] = "{}".format(fee_limit_msat)
|
||||||
lnrpcFeeLimit["percent"] = "1" # in percent
|
|
||||||
else:
|
|
||||||
lnrpcFeeLimit["fixed"] = "10" # in sat
|
|
||||||
|
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
url=f"{self.endpoint}/v1/channels/transactions",
|
url=f"{self.endpoint}/v1/channels/transactions",
|
||||||
|
|
@ -162,6 +160,7 @@ class LndRestWallet(Wallet):
|
||||||
|
|
||||||
# for some reason our checking_ids are in base64 but the payment hashes
|
# for some reason our checking_ids are in base64 but the payment hashes
|
||||||
# returned here are in hex, lnd is weird
|
# returned here are in hex, lnd is weird
|
||||||
|
checking_id = checking_id.replace("_", "/")
|
||||||
checking_id = base64.b64decode(checking_id).hex()
|
checking_id = base64.b64decode(checking_id).hex()
|
||||||
|
|
||||||
for p in r.json()["payments"]:
|
for p in r.json()["payments"]:
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class LNPayWallet(Wallet):
|
||||||
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
|
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class LntxbotWallet(Wallet):
|
||||||
data = r.json()
|
data = r.json()
|
||||||
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
|
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.endpoint}/payinvoice",
|
f"{self.endpoint}/payinvoice",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import getpass
|
||||||
BLOCK_SIZE = 16
|
BLOCK_SIZE = 16
|
||||||
import getpass
|
import getpass
|
||||||
|
|
||||||
|
|
||||||
def load_macaroon(macaroon: str) -> str:
|
def load_macaroon(macaroon: str) -> str:
|
||||||
"""Returns hex version of a macaroon encoded in base64 or the file path.
|
"""Returns hex version of a macaroon encoded in base64 or the file path.
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str:
|
||||||
pass
|
pass
|
||||||
return macaroon
|
return macaroon
|
||||||
|
|
||||||
|
|
||||||
class AESCipher(object):
|
class AESCipher(object):
|
||||||
"""This class is compatible with crypto-js/aes.js
|
"""This class is compatible with crypto-js/aes.js
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ class AESCipher(object):
|
||||||
AES.decrypt(encrypted, password).toString(Utf8);
|
AES.decrypt(encrypted, password).toString(Utf8);
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, key=None, description=""):
|
def __init__(self, key=None, description=""):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.description = description + " "
|
self.description = description + " "
|
||||||
|
|
@ -47,7 +50,6 @@ class AESCipher(object):
|
||||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||||
return data + (chr(length) * length).encode()
|
return data + (chr(length) * length).encode()
|
||||||
|
|
||||||
|
|
||||||
def unpad(self, data):
|
def unpad(self, data):
|
||||||
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
|
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
|
||||||
|
|
||||||
|
|
@ -70,8 +72,7 @@ class AESCipher(object):
|
||||||
return final_key[:output]
|
return final_key[:output]
|
||||||
|
|
||||||
def decrypt(self, encrypted: str) -> str:
|
def decrypt(self, encrypted: str) -> str:
|
||||||
"""Decrypts a string using AES-256-CBC.
|
"""Decrypts a string using AES-256-CBC."""
|
||||||
"""
|
|
||||||
passphrase = self.passphrase
|
passphrase = self.passphrase
|
||||||
encrypted = base64.b64decode(encrypted)
|
encrypted = base64.b64decode(encrypted)
|
||||||
assert encrypted[0:8] == b"Salted__"
|
assert encrypted[0:8] == b"Salted__"
|
||||||
|
|
@ -92,7 +93,10 @@ class AESCipher(object):
|
||||||
key = key_iv[:32]
|
key = key_iv[:32]
|
||||||
iv = key_iv[32:]
|
iv = key_iv[32:]
|
||||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||||
return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode()
|
return base64.b64encode(
|
||||||
|
b"Salted__" + salt + aes.encrypt(self.pad(message))
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
|
||||||
# if this file is executed directly, ask for a macaroon and encrypt it
|
# if this file is executed directly, ask for a macaroon and encrypt it
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class OpenNodeWallet(Wallet):
|
||||||
payment_request = data["lightning_invoice"]["payreq"]
|
payment_request = data["lightning_invoice"]["payreq"]
|
||||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.endpoint}/v2/withdrawals",
|
f"{self.endpoint}/v2/withdrawals",
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,12 @@ class SparkWallet(Wallet):
|
||||||
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
try:
|
try:
|
||||||
r = await self.pay(bolt11)
|
r = await self.pay(
|
||||||
|
bolt11=bolt11,
|
||||||
|
maxfee=fee_limit_msat,
|
||||||
|
)
|
||||||
except (SparkError, UnknownError) as exc:
|
except (SparkError, UnknownError) as exc:
|
||||||
listpays = await self.listpays(bolt11)
|
listpays = await self.listpays(bolt11)
|
||||||
if listpays:
|
if listpays:
|
||||||
|
|
@ -129,7 +132,9 @@ class SparkWallet(Wallet):
|
||||||
if pay["status"] == "failed":
|
if pay["status"] == "failed":
|
||||||
return PaymentResponse(False, None, 0, None, str(exc))
|
return PaymentResponse(False, None, 0, None, str(exc))
|
||||||
elif pay["status"] == "pending":
|
elif pay["status"] == "pending":
|
||||||
return PaymentResponse(None, payment_hash, 0, None, None)
|
return PaymentResponse(
|
||||||
|
None, payment_hash, fee_limit_msat, None, None
|
||||||
|
)
|
||||||
elif pay["status"] == "complete":
|
elif pay["status"] == "complete":
|
||||||
r = pay
|
r = pay
|
||||||
r["payment_preimage"] = pay["preimage"]
|
r["payment_preimage"] = pay["preimage"]
|
||||||
|
|
@ -152,9 +157,11 @@ class SparkWallet(Wallet):
|
||||||
|
|
||||||
if not r or not r.get("invoices"):
|
if not r or not r.get("invoices"):
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
if r["invoices"][0]["status"] == "unpaid":
|
|
||||||
return PaymentStatus(False)
|
if r["invoices"][0]["status"] == "paid":
|
||||||
return PaymentStatus(True)
|
return PaymentStatus(True)
|
||||||
|
else:
|
||||||
|
return PaymentStatus(False)
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
# check if it's 32 bytes hex
|
# check if it's 32 bytes hex
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class VoidWallet(Wallet):
|
||||||
)
|
)
|
||||||
return StatusResponse(None, 0)
|
return StatusResponse(None, 0)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
raise Unsupported("")
|
raise Unsupported("")
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ def app():
|
||||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(app):
|
async def client(app):
|
||||||
client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}')
|
client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}")
|
||||||
# yield and pass the client to the test
|
# yield and pass the client to the test
|
||||||
yield client
|
yield client
|
||||||
# close the async client after the test has finished
|
# close the async client after the test has finished
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue