mirror of
https://github.com/lnbits/lnbits.git
synced 2025-06-05 12:41:21 +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]]]] = []
|
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
|
A method intended for extensions to call when they want to be notified about
|
||||||
new invoice payments incoming.
|
new invoice payments incoming.
|
||||||
"""
|
"""
|
||||||
print("registering callback", callback)
|
print(f"registering {ext_name} invoice_listener callback: {cb}")
|
||||||
invoice_listeners.append((ext_name, callback))
|
invoice_listeners.append((ext_name, cb))
|
||||||
|
|
||||||
|
|
||||||
async def webhook_handler():
|
async def webhook_handler():
|
||||||
@ -61,7 +61,6 @@ async def invoice_listener(app):
|
|||||||
|
|
||||||
async def _invoice_listener():
|
async def _invoice_listener():
|
||||||
async for checking_id in WALLET.paid_invoices_stream():
|
async for checking_id in WALLET.paid_invoices_stream():
|
||||||
# do this just so the g object is available
|
|
||||||
g.db = await open_db()
|
g.db = await open_db()
|
||||||
payment = await get_standalone_payment(checking_id)
|
payment = await get_standalone_payment(checking_id)
|
||||||
if payment.is_in:
|
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_api import * # noqa
|
||||||
from .views import * # noqa
|
from .views import * # noqa
|
||||||
|
from .lnurl import * # noqa
|
||||||
from .tasks import on_invoice_paid
|
from .tasks import on_invoice_paid
|
||||||
|
|
||||||
from lnbits.core.tasks import register_invoice_listener
|
from lnbits.core.tasks import register_invoice_listener
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
from lnbits.db import open_ext_db
|
from lnbits.db import open_ext_db
|
||||||
|
|
||||||
from .models import PayLink
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink
|
|||||||
description,
|
description,
|
||||||
amount,
|
amount,
|
||||||
served_meta,
|
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
|
link_id = db.cursor.lastrowid
|
||||||
return get_pay_link(link_id)
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||||
|
|
||||||
return PayLink.from_row(row) if row else None
|
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:
|
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
|
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]
|
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()])
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
|
||||||
with open_ext_db("lnurlp") as db:
|
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
|
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()])
|
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||||
|
|
||||||
with open_ext_db("lnurlp") as db:
|
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
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
|
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):
|
def m002_webhooks_and_success_actions(db):
|
||||||
# """
|
"""
|
||||||
# Webhooks and success actions.
|
Webhooks and success actions.
|
||||||
# """
|
"""
|
||||||
# db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
|
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_text TEXT;")
|
||||||
# db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
|
db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
|
||||||
# db.execute(
|
db.execute(
|
||||||
# """
|
"""
|
||||||
# CREATE TABLE invoices (
|
CREATE TABLE invoices (
|
||||||
# payment_hash PRIMARY KEY,
|
pay_link INTEGER NOT NULL REFERENCES pay_links (id),
|
||||||
# link_id INTEGER NOT NULL REFERENCES pay_links (id),
|
payment_hash TEXT NOT NULL,
|
||||||
# webhook_sent BOOLEAN NOT NULL DEFAULT false
|
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 lnbits.core.models import Payment
|
||||||
|
|
||||||
|
from .crud import get_pay_link_by_invoice, mark_webhook_sent
|
||||||
|
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment) -> None:
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
islnurlp = "lnurlp" in payment.extra.get("tags", {})
|
islnurlp = "lnurlp" == payment.extra.get("tag")
|
||||||
print("invoice paid on lnurlp?", islnurlp)
|
|
||||||
if islnurlp:
|
if islnurlp:
|
||||||
print("dispatching webhook")
|
pay_link = get_pay_link_by_invoice(payment.payment_hash)
|
||||||
async with aiohttp.ClientSession() as session:
|
if not pay_link:
|
||||||
await session.post("https://fiatjaf.free.beeceptor.com", json=payment)
|
# 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"
|
type="number"
|
||||||
label="Amount (sat) *"
|
label="Amount (sat) *"
|
||||||
></q-input>
|
></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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="formDialog.data.id"
|
v-if="formDialog.data.id"
|
||||||
@ -149,7 +156,8 @@
|
|||||||
(
|
(
|
||||||
formDialog.data.amount == null ||
|
formDialog.data.amount == null ||
|
||||||
formDialog.data.amount < 1
|
formDialog.data.amount < 1
|
||||||
)"
|
)
|
||||||
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create pay link</q-btn
|
>Create pay link</q-btn
|
||||||
>
|
>
|
||||||
@ -174,6 +182,7 @@
|
|||||||
<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>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
|
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
|
||||||
|
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
|
||||||
</p>
|
</p>
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
@ -248,6 +257,12 @@
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
label: 'Amount (sat)',
|
label: 'Amount (sat)',
|
||||||
field: 'amount'
|
field: 'amount'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webhook_url',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Webhook URL',
|
||||||
|
field: 'webhook_url'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
@ -331,7 +346,7 @@
|
|||||||
'PUT',
|
'PUT',
|
||||||
'/lnurlp/api/v1/links/' + data.id,
|
'/lnurlp/api/v1/links/' + data.id,
|
||||||
wallet.adminkey,
|
wallet.adminkey,
|
||||||
_.pick(data, 'description', 'amount')
|
_.pick(data, 'description', 'amount', 'webhook_url')
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.payLinks = _.reject(self.payLinks, function (obj) {
|
self.payLinks = _.reject(self.payLinks, function (obj) {
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
import hashlib
|
from quart import g, jsonify, request
|
||||||
from quart import g, jsonify, request, url_for
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse
|
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
|
|
||||||
from lnbits import bolt11
|
|
||||||
from lnbits.core.crud import get_user
|
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.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
from lnbits.extensions.lnurlp import lnurlp_ext
|
from lnbits.extensions.lnurlp import lnurlp_ext
|
||||||
@ -15,7 +11,6 @@ from .crud import (
|
|||||||
get_pay_link,
|
get_pay_link,
|
||||||
get_pay_links,
|
get_pay_links,
|
||||||
update_pay_link,
|
update_pay_link,
|
||||||
increment_pay_link,
|
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,6 +56,7 @@ async def api_link_retrieve(link_id):
|
|||||||
schema={
|
schema={
|
||||||
"description": {"type": "string", "empty": False, "required": True},
|
"description": {"type": "string", "empty": False, "required": True},
|
||||||
"amount": {"type": "integer", "min": 1, "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):
|
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)
|
delete_pay_link(link_id)
|
||||||
|
|
||||||
return "", HTTPStatus.NO_CONTENT
|
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