mirror of
https://github.com/lnbits/lnbits.git
synced 2025-05-30 09:39:49 +02:00
lnurlp webhooks.
This commit is contained in:
parent
04222f1f01
commit
74117ffc57
@ -40,13 +40,13 @@ def run_on_pseudo_request(awaitable: Awaitable):
|
||||
invoice_listeners: List[Tuple[str, Callable[[Payment], Awaitable[None]]]] = []
|
||||
|
||||
|
||||
def register_invoice_listener(ext_name: str, callback: Callable[[Payment], Awaitable[None]]):
|
||||
def register_invoice_listener(ext_name: str, cb: Callable[[Payment], Awaitable[None]]):
|
||||
"""
|
||||
A method intended for extensions to call when they want to be notified about
|
||||
new invoice payments incoming.
|
||||
"""
|
||||
print("registering callback", callback)
|
||||
invoice_listeners.append((ext_name, callback))
|
||||
print(f"registering {ext_name} invoice_listener callback: {cb}")
|
||||
invoice_listeners.append((ext_name, cb))
|
||||
|
||||
|
||||
async def webhook_handler():
|
||||
@ -61,7 +61,6 @@ async def invoice_listener(app):
|
||||
|
||||
async def _invoice_listener():
|
||||
async for checking_id in WALLET.paid_invoices_stream():
|
||||
# do this just so the g object is available
|
||||
g.db = await open_db()
|
||||
payment = await get_standalone_payment(checking_id)
|
||||
if payment.is_in:
|
||||
|
@ -6,6 +6,7 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import on_invoice_paid
|
||||
|
||||
from lnbits.core.tasks import register_invoice_listener
|
||||
|
@ -1,11 +1,12 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
from .models import PayLink
|
||||
|
||||
|
||||
def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink:
|
||||
def create_pay_link(*, wallet_id: str, description: str, amount: int, webhook_url: str) -> Optional[PayLink]:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink
|
||||
description,
|
||||
amount,
|
||||
served_meta,
|
||||
served_pr
|
||||
served_pr,
|
||||
webhook_url
|
||||
)
|
||||
VALUES (?, ?, ?, 0, 0)
|
||||
VALUES (?, ?, ?, 0, 0, ?)
|
||||
""",
|
||||
(wallet_id, description, amount),
|
||||
(wallet_id, description, amount, webhook_url),
|
||||
)
|
||||
link_id = db.cursor.lastrowid
|
||||
return get_pay_link(link_id)
|
||||
|
||||
|
||||
def get_pay_link(link_id: str) -> Optional[PayLink]:
|
||||
def get_pay_link(link_id: int) -> Optional[PayLink]:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]:
|
||||
def get_pay_link_by_invoice(payment_hash: str) -> Optional[PayLink]:
|
||||
# this excludes invoices with webhooks that have been sent already
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_hash,))
|
||||
row = db.fetchone(
|
||||
"""
|
||||
SELECT pay_links.* FROM pay_links
|
||||
INNER JOIN invoices ON invoices.pay_link = pay_links.id
|
||||
WHERE payment_hash = ? AND webhook_sent IS NULL
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
@ -49,7 +60,7 @@ def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||
return [PayLink.from_row(row) for row in rows]
|
||||
|
||||
|
||||
def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
|
||||
def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
@ -59,7 +70,7 @@ def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
|
||||
def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
@ -69,6 +80,30 @@ def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def delete_pay_link(link_id: str) -> None:
|
||||
def delete_pay_link(link_id: int) -> None:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
def save_link_invoice(link_id: int, payment_request: str) -> None:
|
||||
inv = bolt11.decode(payment_request)
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO invoices (pay_link, payment_hash, expiry)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(link_id, inv.payment_hash, inv.expiry),
|
||||
)
|
||||
|
||||
|
||||
def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE invoices SET webhook_sent = ?
|
||||
WHERE payment_hash = ?
|
||||
""",
|
||||
(status, payment_hash),
|
||||
)
|
||||
|
49
lnbits/extensions/lnurlp/lnurl.py
Normal file
49
lnbits/extensions/lnurlp/lnurl.py
Normal file
@ -0,0 +1,49 @@
|
||||
import hashlib
|
||||
from http import HTTPStatus
|
||||
from quart import jsonify, url_for
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from lnbits.extensions.lnurlp import lnurlp_ext
|
||||
from .crud import increment_pay_link, save_link_invoice
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_response(link_id):
|
||||
link = increment_pay_link(link_id, served_meta=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url,
|
||||
min_sendable=link.amount * 1000,
|
||||
max_sendable=link.amount * 1000,
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_callback(link_id):
|
||||
link = increment_pay_link(link_id, served_pr=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
_, payment_request = create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=link.amount,
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
|
||||
extra={"tag": "lnurlp"},
|
||||
)
|
||||
|
||||
save_link_invoice(link_id, payment_request)
|
||||
|
||||
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
@ -16,19 +16,20 @@ def m001_initial(db):
|
||||
)
|
||||
|
||||
|
||||
# def m002_webhooks_and_success_actions(db):
|
||||
# """
|
||||
# Webhooks and success actions.
|
||||
# """
|
||||
# db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
|
||||
# db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
|
||||
# db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
|
||||
# db.execute(
|
||||
# """
|
||||
# CREATE TABLE invoices (
|
||||
# payment_hash PRIMARY KEY,
|
||||
# link_id INTEGER NOT NULL REFERENCES pay_links (id),
|
||||
# webhook_sent BOOLEAN NOT NULL DEFAULT false
|
||||
# );
|
||||
# """
|
||||
# )
|
||||
def m002_webhooks_and_success_actions(db):
|
||||
"""
|
||||
Webhooks and success actions.
|
||||
"""
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE invoices (
|
||||
pay_link INTEGER NOT NULL REFERENCES pay_links (id),
|
||||
payment_hash TEXT NOT NULL,
|
||||
webhook_sent INT, -- null means not sent, otherwise store status
|
||||
expiry INT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
@ -2,11 +2,29 @@ import aiohttp
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
|
||||
from .crud import get_pay_link_by_invoice, mark_webhook_sent
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
islnurlp = "lnurlp" in payment.extra.get("tags", {})
|
||||
print("invoice paid on lnurlp?", islnurlp)
|
||||
islnurlp = "lnurlp" == payment.extra.get("tag")
|
||||
if islnurlp:
|
||||
print("dispatching webhook")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post("https://fiatjaf.free.beeceptor.com", json=payment)
|
||||
pay_link = get_pay_link_by_invoice(payment.payment_hash)
|
||||
if not pay_link:
|
||||
# no pay_link or this webhook has already been sent
|
||||
return
|
||||
if pay_link.webhook_url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
r = await session.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"lnurlp": pay_link.id,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
mark_webhook_sent(payment.payment_hash, r.status)
|
||||
except aiohttp.client_exceptions.ClientError:
|
||||
mark_webhook_sent(payment.payment_hash, -1)
|
||||
|
@ -131,6 +131,13 @@
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.webhook_url"
|
||||
type="text"
|
||||
label="Webhook URL (optional)"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
@ -149,7 +156,8 @@
|
||||
(
|
||||
formDialog.data.amount == null ||
|
||||
formDialog.data.amount < 1
|
||||
)"
|
||||
)
|
||||
"
|
||||
type="submit"
|
||||
>Create pay link</q-btn
|
||||
>
|
||||
@ -174,6 +182,7 @@
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
|
||||
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
@ -248,6 +257,12 @@
|
||||
align: 'right',
|
||||
label: 'Amount (sat)',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
name: 'webhook_url',
|
||||
align: 'left',
|
||||
label: 'Webhook URL',
|
||||
field: 'webhook_url'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
@ -331,7 +346,7 @@
|
||||
'PUT',
|
||||
'/lnurlp/api/v1/links/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'description', 'amount')
|
||||
_.pick(data, 'description', 'amount', 'webhook_url')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.payLinks = _.reject(self.payLinks, function (obj) {
|
||||
|
@ -1,12 +1,8 @@
|
||||
import hashlib
|
||||
from quart import g, jsonify, request, url_for
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.lnurlp import lnurlp_ext
|
||||
@ -15,7 +11,6 @@ from .crud import (
|
||||
get_pay_link,
|
||||
get_pay_links,
|
||||
update_pay_link,
|
||||
increment_pay_link,
|
||||
delete_pay_link,
|
||||
)
|
||||
|
||||
@ -61,6 +56,7 @@ async def api_link_retrieve(link_id):
|
||||
schema={
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"amount": {"type": "integer", "min": 1, "required": True},
|
||||
"webhook_url": {"type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
async def api_link_create_or_update(link_id=None):
|
||||
@ -94,43 +90,3 @@ async def api_link_delete(link_id):
|
||||
delete_pay_link(link_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_response(link_id):
|
||||
link = increment_pay_link(link_id, served_meta=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url,
|
||||
min_sendable=link.amount * 1000,
|
||||
max_sendable=link.amount * 1000,
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_callback(link_id):
|
||||
link = increment_pay_link(link_id, served_pr=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
_, payment_request = create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=link.amount,
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
|
||||
extra={"tag": "lnurlp"},
|
||||
)
|
||||
|
||||
inv = bolt11.decode(payment_request)
|
||||
inv.payment_hash
|
||||
|
||||
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
|
Loading…
x
Reference in New Issue
Block a user