Merge remote-tracking branch 'origin/master' into StreamerCopilot

This commit is contained in:
Ben Arc 2021-04-20 10:48:43 +01:00
commit fe6e6764fa
19 changed files with 503 additions and 117 deletions

View File

@ -2,13 +2,14 @@ import json
import datetime
from uuid import uuid4
from typing import List, Optional, Dict, Any
from urllib.parse import urlparse
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.settings import DEFAULT_WALLET_NAME
from . import db
from .models import User, Wallet, Payment
from .models import User, Wallet, Payment, BalanceCheck
# accounts
@ -379,3 +380,77 @@ async def check_internal(
return None
else:
return row["checking_id"]
# balance_check
# -------------
async def save_balance_check(
wallet_id: str,
url: str,
conn: Optional[Connection] = None,
):
domain = urlparse(url).netloc
await (conn or db).execute(
"""
INSERT OR REPLACE INTO balance_check (wallet, service, url)
VALUES (?, ?, ?)
""",
(wallet_id, domain, url),
)
async def get_balance_check(
wallet_id: str,
domain: str,
conn: Optional[Connection] = None,
) -> Optional[BalanceCheck]:
row = await (conn or db).fetchone(
"""
SELECT wallet, service, url
FROM balance_check
WHERE wallet = ? AND service = ?
""",
(wallet_id, domain),
)
return BalanceCheck.from_row(row) if row else None
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
return [BalanceCheck.from_row(row) for row in rows]
# balance_notify
# --------------
async def save_balance_notify(
wallet_id: str,
url: str,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT OR REPLACE INTO balance_notify (wallet, url)
VALUES (?, ?)
""",
(wallet_id, url),
)
async def get_balance_notify(
wallet_id: str,
conn: Optional[Connection] = None,
) -> Optional[str]:
row = await (conn or db).fetchone(
"""
SELECT url
FROM balance_notify
WHERE wallet = ?
""",
(wallet_id,),
)
return row[0] if row else None

View File

@ -161,3 +161,32 @@ async def m004_ensure_fees_are_always_negative(db):
GROUP BY wallet;
"""
)
async def m005_balance_check_balance_notify(db):
"""
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
"""
await db.execute(
"""
CREATE TABLE balance_check (
wallet INTEGER NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL,
url TEXT NOT NULL,
UNIQUE(wallet, service)
);
"""
)
await db.execute(
"""
CREATE TABLE balance_notify (
wallet INTEGER NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL,
UNIQUE(wallet, url)
);
"""
)

View File

@ -1,7 +1,9 @@
import json
import hmac
import hashlib
from quart import url_for
from ecdsa import SECP256k1, SigningKey # type: ignore
from lnurl import encode as lnurl_encode # type: ignore
from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row
@ -36,6 +38,22 @@ class Wallet(NamedTuple):
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for(
"core.lnurl_full_withdraw",
usr=self.user,
wal=self.id,
_external=True,
)
return lnurl_encode(url)
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
@ -158,3 +176,13 @@ class Payment(NamedTuple):
from .crud import delete_payment
await delete_payment(self.checking_id)
class BalanceCheck(NamedTuple):
wallet: str
service: str
url: str
@classmethod
def from_row(cls, row: Row):
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])

View File

@ -1,11 +1,12 @@
import trio # type: ignore
import json
import httpx
from io import BytesIO
from binascii import unhexlify
from typing import Optional, Tuple, Dict
from urllib.parse import urlparse, parse_qs
from quart import g
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
from quart import g, url_for
from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore
try:
from typing import TypedDict # type: ignore
@ -128,10 +129,9 @@ async def pay_invoice(
else:
# create a temporary payment here so we can check if
# the balance is enough in the next step
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
await create_payment(
checking_id=temp_id,
fee=-fee_reserve,
fee=-fee_reserve(invoice.amount_msat),
conn=conn,
**payment_kwargs,
)
@ -180,25 +180,49 @@ async def pay_invoice(
async def redeem_lnurl_withdraw(
wallet_id: str,
res: LnurlWithdrawResponse,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
res = {}
async with httpx.AsyncClient() as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=res.max_sats,
memo=memo or res.default_description or "",
extra={"tag": "lnurlwallet"},
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
if wait_seconds:
await trio.sleep(wait_seconds)
params = {
"k1": res["k1"],
"pr": payment_request,
}
try:
params["balanceNotify"] = url_for(
"core.lnurl_balance_notify",
service=urlparse(lnurl_request).netloc,
wal=wallet_id,
_external=True,
)
except Exception:
pass
async with httpx.AsyncClient() as client:
await client.get(
res.callback.base,
params={
**res.callback.query_params,
**{"k1": res.k1, "pr": payment_request},
},
res["callback"],
params=params,
)
@ -286,3 +310,7 @@ async def check_invoice_status(
return PaymentStatus(None)
return await WALLET.get_invoice_status(payment.checking_id)
def fee_reserve(amount_msat: int) -> int:
return max(1000, int(amount_msat * 0.01))

View File

@ -3,7 +3,9 @@ import httpx
from typing import List
from lnbits.tasks import register_invoice_listener
from . import db
from .crud import get_balance_notify
from .models import Payment
sse_listeners: List[trio.MemorySendChannel] = []
@ -24,6 +26,19 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
# dispatch balance_notify
url = await get_balance_notify(payment.wallet_id)
if url:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
url,
timeout=4,
)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
pass
async def dispatch_sse(payment: Payment):
for send_channel in sse_listeners:

View File

@ -4,7 +4,7 @@
<!---->
{% block scripts %} {{ window_vars(user, wallet) }}
<script src="/core/static/js/wallet.js"></script>
<link rel="manifest" href="/manifest/{{ user.id }}.webmanifest">
<link rel="manifest" href="/manifest/{{ user.id }}.webmanifest" />
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
@ -231,13 +231,39 @@
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="crop_free"
label="Drain Funds"
>
<q-card>
<q-card-section class="text-center">
<p>
This is an LNURL-withdraw QR code for slurping everything from
this wallet. Do not share with anyone.
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
:options="{width:240}"
></qrcode>
</a>
<p>
It is compatible with <code>balanceCheck</code> and
<code>balanceNotify</code> so your wallet may keep pulling the
funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section>
<q-card-section class="text-center">
<p>
This QR code contains your wallet URL with full access. You
can scan it from your phone to open your wallet from there.

View File

@ -3,7 +3,7 @@ import json
import lnurl # type: ignore
import httpx
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, make_response
from quart import g, jsonify, make_response, url_for
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
@ -12,6 +12,7 @@ from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from .. import core_app, db
from ..crud import save_balance_check
from ..services import (
PaymentFailure,
InvoiceFailure,
@ -60,6 +61,7 @@ async def api_payments():
"excludes": "memo",
},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
"lnurl_balance_check": {"type": "string", "required": False},
"extra": {"type": "dict", "nullable": True, "required": False},
"webhook": {"type": "string", "empty": False, "required": False},
}
@ -92,11 +94,22 @@ async def api_payments_create_invoice():
lnurl_response: Union[None, bool, str] = None
if g.data.get("lnurl_callback"):
if "lnurl_balance_check" in g.data:
save_balance_check(g.wallet.id, g.data["lnurl_balance_check"])
async with httpx.AsyncClient() as client:
try:
r = await client.get(
g.data["lnurl_callback"],
params={"pr": payment_request},
params={
"pr": payment_request,
"balanceNotify": url_for(
"core.lnurl_balance_notify",
service=urlparse(g.data["lnurl_callback"]).netloc,
wal=g.wallet.id,
_external=True,
),
},
timeout=10,
)
if r.is_error:
@ -387,6 +400,12 @@ async def api_lnurlscan(code: str):
parsed_callback: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data.k1
# balanceCheck/balanceNotify
if "balanceCheck" in jdata:
params.update(balanceCheck=jdata["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(parsed_callback))

View File

@ -1,5 +1,3 @@
import trio # type: ignore
import httpx
from os import path
from http import HTTPStatus
from quart import (
@ -12,11 +10,10 @@ from quart import (
send_from_directory,
url_for,
)
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
from lnbits.core import core_app, db
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE
from ..crud import (
create_account,
@ -24,8 +21,10 @@ from ..crud import (
update_user_extension,
create_wallet,
delete_wallet,
get_balance_check,
save_balance_notify,
)
from ..services import redeem_lnurl_withdraw
from ..services import redeem_lnurl_withdraw, pay_invoice
@core_app.route("/favicon.ico")
@ -108,6 +107,62 @@ async def wallet():
)
@core_app.route("/withdraw")
@validate_uuids(["usr", "wal"], required=True)
async def lnurl_full_withdraw():
user = await get_user(request.args.get("usr"))
if not user:
return jsonify({"status": "ERROR", "reason": "User does not exist."})
wallet = user.get_wallet(request.args.get("wal"))
if not wallet:
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
return jsonify(
{
"tag": "withdrawRequest",
"callback": url_for(
"core.lnurl_full_withdraw_callback",
usr=user.id,
wal=wallet.id,
_external=True,
),
"k1": "0",
"minWithdrawable": 1 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
"balanceCheck": url_for(
"core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True
),
}
)
@core_app.route("/withdraw/cb")
@validate_uuids(["usr", "wal"], required=True)
async def lnurl_full_withdraw_callback():
user = await get_user(request.args.get("usr"))
if not user:
return jsonify({"status": "ERROR", "reason": "User does not exist."})
wallet = user.get_wallet(request.args.get("wal"))
if not wallet:
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
pr = request.args.get("pr")
async def pay():
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
g.nursery.start_soon(pay)
balance_notify = request.args.get("balanceNotify")
if balance_notify:
await save_balance_notify(wallet.id, balance_notify)
return jsonify({"status": "OK"})
@core_app.route("/deletewallet")
@validate_uuids(["usr", "wal"], required=True)
@check_user_exists()
@ -127,31 +182,16 @@ async def deletewallet():
return redirect(url_for("core.home"))
@core_app.route("/withdraw/notify/<service>")
@validate_uuids(["wal"], required=True)
async def lnurl_balance_notify(service: str):
bc = await get_balance_check(request.args.get("wal"), service)
if bc:
redeem_lnurl_withdraw(bc.wallet, bc.url)
@core_app.route("/lnurlwallet")
async def lnurlwallet():
async with httpx.AsyncClient() as client:
try:
lnurl = decode_lnurl(request.args.get("lightning"))
r = await client.get(str(lnurl))
withdraw_res = LnurlResponse.from_dict(r.json())
if not withdraw_res.ok:
return (
f"Could not process lnurl-withdraw: {withdraw_res.error_msg}",
HTTPStatus.BAD_REQUEST,
)
if not isinstance(withdraw_res, LnurlWithdrawResponse):
return (
f"Expected an lnurl-withdraw code, got {withdraw_res.tag}",
HTTPStatus.BAD_REQUEST,
)
except Exception as exc:
return (
f"Could not process lnurl-withdraw: {exc}",
HTTPStatus.INTERNAL_SERVER_ERROR,
)
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
@ -160,10 +200,11 @@ async def lnurlwallet():
g.nursery.start_soon(
redeem_lnurl_withdraw,
wallet.id,
withdraw_res,
request.args.get("lightning"),
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
)
await trio.sleep(3)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))

View File

@ -12,11 +12,9 @@ function ccreateIframeElement(t = {}) {
t.dest, t.amount, t.currency, t.label, t.opReturn
var captchaid = document
.getElementById('captchascript')
.getAttribute('data-captchaid');
var lnbhostsrc = document
.getElementById('captchascript')
.getAttribute('src');
var lnbhost = lnbhostsrc.split("/captcha/static/js/captcha.js")[0];
.getAttribute('data-captchaid')
var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
return (e.src = lnbhost + '/captcha/' + captchaid), e
}
document.addEventListener('DOMContentLoaded', function () {

View File

@ -1,4 +1,3 @@
import unicodedata
from typing import List, Optional
from lnbits.core.crud import create_account, create_wallet
@ -65,22 +64,36 @@ async def add_track(
name: str,
download_url: Optional[str],
price_msat: int,
producer_name: Optional[str],
producer_id: Optional[int],
producer: Optional[int],
) -> int:
if producer_id:
p_id = producer_id
elif producer_name:
p_id = await add_producer(livestream, producer_name)
else:
raise TypeError("need either producer_id or producer_name arguments")
result = await db.execute(
"""
INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
VALUES (?, ?, ?, ?, ?)
""",
(livestream, name, download_url, price_msat, p_id),
(livestream, name, download_url, price_msat, producer),
)
return result._result_proxy.lastrowid
async def update_track(
livestream: int,
track_id: int,
name: str,
download_url: Optional[str],
price_msat: int,
producer: int,
) -> int:
result = await db.execute(
"""
UPDATE tracks SET
name = ?,
download_url = ?,
price_msat = ?,
producer = ?
WHERE livestream = ? AND id = ?
""",
(name, download_url, price_msat, producer, livestream, track_id),
)
return result._result_proxy.lastrowid
@ -120,7 +133,7 @@ async def delete_track_from_livestream(livestream: int, track_id: int):
async def add_producer(livestream: int, name: str) -> int:
name = "".join([unicodedata.normalize("NFD", l)[0] for l in name if l]).strip()
name = name.strip()
existing = await db.fetchall(
"""

View File

@ -10,7 +10,7 @@ from .crud import get_livestream, get_livestream_by_track, get_track
@livestream_ext.route("/lnurl/<ls_id>", methods=["GET"])
async def lnurl_response(ls_id):
async def lnurl_livestream(ls_id):
ls = await get_livestream(ls_id)
if not ls:
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
@ -34,6 +34,27 @@ async def lnurl_response(ls_id):
return jsonify(params)
@livestream_ext.route("/lnurl/t/<track_id>", methods=["GET"])
async def lnurl_track(track_id):
track = await get_track(track_id)
if not track:
return jsonify({"status": "ERROR", "reason": "Track not found."})
resp = LnurlPayResponse(
callback=url_for(
"livestream.lnurl_callback", track_id=track.id, _external=True
),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return jsonify(params)
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
async def lnurl_callback(track_id):
track = await get_track(track_id)

View File

@ -14,7 +14,7 @@ class Livestream(NamedTuple):
@property
def lnurl(self) -> Lnurl:
url = url_for("livestream.lnurl_response", ls_id=self.id, _external=True)
url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True)
return lnurl_encode(url)
@ -33,6 +33,11 @@ class Track(NamedTuple):
def max_sendable(self) -> int:
return max(50_000_000, self.price_msat * 5)
@property
def lnurl(self) -> Lnurl:
url = url_for("livestream.lnurl_track", track_id=self.id, _external=True)
return lnurl_encode(url)
async def fullname(self) -> str:
from .crud import get_producer

View File

@ -93,24 +93,21 @@ new Vue({
)
},
addTrack() {
let {name, producer, price_sat, download_url} = this.trackDialog.data
let {id, name, producer, price_sat, download_url} = this.trackDialog.data
const [method, path] = id
? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`]
: ['POST', '/livestream/api/v1/livestream/tracks']
LNbits.api
.request(
'POST',
'/livestream/api/v1/livestream/tracks',
this.selectedWallet.inkey,
{
.request(method, path, this.selectedWallet.inkey, {
download_url:
download_url && download_url.length > 0
? download_url
: undefined,
download_url && download_url.length > 0 ? download_url : undefined,
name,
price_msat: price_sat * 1000 || 0,
producer_name: typeof producer === 'string' ? producer : undefined,
producer_id: typeof producer === 'object' ? producer.id : undefined
}
)
})
.then(response => {
this.$q.notify({
message: `Track '${this.trackDialog.data.name}' added.`,
@ -124,6 +121,21 @@ new Vue({
LNbits.utils.notifyApiError(err)
})
},
openAddTrackDialog() {
this.trackDialog.show = true
this.trackDialog.data = {}
},
openUpdateDialog(itemId) {
this.trackDialog.show = true
let item = this.livestream.tracks.find(item => item.id === itemId)
this.trackDialog.data = {
...item,
producer: this.livestream.producers.find(
prod => prod.id === item.producer
),
price_sat: Math.round(item.price_msat / 1000)
}
},
deleteTrack(trackId) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this track?')

View File

@ -61,10 +61,7 @@
<h5 class="text-subtitle1 q-my-none">Tracks</h5>
</div>
<div class="col q-ml-lg">
<q-btn
unelevated
color="deep-purple"
@click="trackDialog.show = true"
<q-btn unelevated color="deep-purple" @click="openAddTrackDialog"
>Add new track</q-btn
>
</div>
@ -107,12 +104,20 @@
{{ producersMap[props.row.producer].name }}
</q-td>
<q-td class="text-right" auto-width
>{{ props.row.price_msat }}</q-td
>{{ Math.round(props.row.price_msat / 1000) }}</q-td
>
<q-td class="text-center" auto-width
>{{ props.row.download_url }}</q-td
>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
@ -186,7 +191,7 @@
</q-select>
</q-form>
<a :href="livestream.url">
<a :href="'lightning:' + livestream.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="livestream.lnurl"
@ -221,6 +226,31 @@
<q-dialog v-model="trackDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section
v-if="trackDialog.data.lnurl"
class="q-pa-none text-center"
>
<p class="text-subtitle1 q-my-none">
Standalone QR Code for this track
</p>
<a :href="'lightning:' + trackDialog.data.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="trackDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(trackDialog.data.lnurl)"
class="text-center q-mb-md"
>Copy LNURL-pay code</q-btn
>
</q-card-section>
<q-card-section>
<q-form @submit="addTrack" class="q-gutter-md">
<q-select
@ -269,8 +299,10 @@
color="deep-purple"
:disable="disabledAddTrackButton()"
type="submit"
>Add track</q-btn
>
<span v-if="trackDialog.data.id">Update track</span>
<span v-else>Add track</span>
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"

View File

@ -9,9 +9,11 @@ from .crud import (
get_or_create_livestream_by_wallet,
add_track,
get_tracks,
update_track,
add_producer,
get_producers,
update_livestream_fee,
update_current_track,
update_livestream_fee,
delete_track_from_livestream,
)
@ -30,7 +32,10 @@ async def api_livestream_from_wallet():
**ls._asdict(),
**{
"lnurl": ls.lnurl,
"tracks": [track._asdict() for track in tracks],
"tracks": [
dict(lnurl=track.lnurl, **track._asdict())
for track in tracks
],
"producers": [producer._asdict() for producer in producers],
},
}
@ -72,6 +77,7 @@ async def api_update_fee(fee_pct):
@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"])
@livestream_ext.route("/api/v1/livestream/tracks/<id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
@ -90,15 +96,33 @@ async def api_update_fee(fee_pct):
},
}
)
async def api_add_track():
async def api_add_track(id=None):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
if "producer_id" in g.data:
p_id = g.data["producer_id"]
elif "producer_name" in g.data:
p_id = await add_producer(ls.id, g.data["producer_name"])
else:
raise TypeError("need either producer_id or producer_name arguments")
if id:
await update_track(
ls.id,
id,
g.data["name"],
g.data.get("download_url"),
g.data.get("price_msat", 0),
p_id,
)
return "", HTTPStatus.OK
else:
await add_track(
ls.id,
g.data["name"],
g.data.get("download_url"),
g.data.get("price_msat", 0),
g.data.get("producer_name"),
g.data.get("producer_id"),
p_id,
)
return "", HTTPStatus.CREATED

View File

@ -17,7 +17,7 @@
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v0/links -H "X-Api-Key: {{
>curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>

View File

@ -9,7 +9,9 @@ from lnbits.core.crud import (
get_payments,
get_standalone_payment,
delete_expired_invoices,
get_balance_checks,
)
from lnbits.core.services import redeem_lnurl_withdraw
main_app: Optional[QuartTrio] = None
@ -93,6 +95,14 @@ async def check_pending_payments():
await trio.sleep(60 * 30) # every 30 minutes
async def perform_balance_checks():
while True:
for bc in await get_balance_checks():
redeem_lnurl_withdraw(bc.wallet, bc.url)
await trio.sleep(60 * 60 * 6) # every 6 hours
async def invoice_callback_dispatcher(checking_id: str):
payment = await get_standalone_payment(checking_id)
if payment and payment.is_in:

View File

@ -14,8 +14,8 @@
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
{% block head_scripts %}{% endblock %}
</head>

View File

@ -1,3 +1,4 @@
import trio # type: ignore
import json
import httpx
from os import getenv
@ -116,16 +117,25 @@ class LNbitsWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"{self.endpoint}/api/v1/payments/sse"
while True:
try:
async with httpx.AsyncClient(timeout=None, headers=self.key) as client:
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
if line.startswith("data:"):
try:
data = json.loads(line[5:])
except json.decoder.JSONDecodeError:
continue
if type(data) is not list or len(data) < 9:
if type(data) is not dict:
continue
yield data[8] # payment_hash
yield data["payment_hash"] # payment_hash
except (OSError, httpx.ReadError, httpx.ConnectError):
pass
print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
await trio.sleep(5)