refactor: better namespace

This commit is contained in:
Vlad Stan
2026-07-08 11:55:08 +03:00
parent 385b3920d4
commit da412a2321
5 changed files with 564 additions and 428 deletions
+48 -372
View File
@@ -7,7 +7,6 @@ import secrets
import time import time
from collections.abc import Awaitable, Callable, Iterable, Mapping from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from functools import wraps from functools import wraps
from typing import Any, TypeVar, cast, get_type_hints from typing import Any, TypeVar, cast, get_type_hints
@@ -16,37 +15,19 @@ from pydantic import BaseModel
from lnbits.helpers import sha256s from lnbits.helpers import sha256s
from .models import ( from .models import (
Bolt11Request,
CreateInvoicePublicRequest, CreateInvoicePublicRequest,
CreateInvoiceRequest, CreateInvoiceRequest,
CreateInvoiceResponse, CreateInvoiceResponse,
CurrencyConvertRequest,
CurrencyConvertResponse,
CurrencyListResponse,
CurrencyRateRequest,
CurrencyRateResponse,
DecodeInvoiceResponse,
EmptyRequest, EmptyRequest,
ExtensionApiRequest, ExtensionApiRequest,
FiatToSatsRequest,
FiatToSatsResponse,
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
InvoiceAmountMsatResponse,
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
ListUserWalletsResponse, ListUserWalletsResponse,
LogRequest, LogRequest,
LogResponse, LogResponse,
NowResponse, NowResponse,
RandomIdRequest, RandomIdRequest,
RandomIdResponse, RandomIdResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
SatsToFiatResponse,
ServerHealthResponse,
StorageDeleteRequest, StorageDeleteRequest,
StorageDeleteResponse, StorageDeleteResponse,
StorageGetRequest, StorageGetRequest,
@@ -56,9 +37,6 @@ from .models import (
StorageSetRequest, StorageSetRequest,
StorageSetResponse, StorageSetResponse,
UserWalletSummary, UserWalletSummary,
ValidateInvoiceResponse,
VerifyPreimageRequest,
VerifyPreimageResponse,
) )
from .storage import ( from .storage import (
storage_delete_row, storage_delete_row,
@@ -80,6 +58,7 @@ class ExtensionAPIMethodExport:
method_id: str method_id: str
namespace: str namespace: str
name: str name: str
host_interface: str
host_name: str host_name: str
sdk_name: str sdk_name: str
description: str description: str
@@ -93,6 +72,7 @@ class ExtensionAPIMethod:
namespace: str namespace: str
name: str name: str
python_name: str python_name: str
host_interface: str
host_name: str host_name: str
sdk_name: str sdk_name: str
description: str description: str
@@ -114,16 +94,18 @@ def extension_api_method(
host_name: str, host_name: str,
sdk_name: str, sdk_name: str,
description: str, description: str,
host_interface: str = "host",
required_permission: str | None = None, required_permission: str | None = None,
require_auth: bool = True, require_auth: bool = True,
) -> Callable[ ) -> Callable[
[Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]], [Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]],
Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]], Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
]: ]:
export = ExtensionAPIMethodExport( export = ExtensionAPIMethodExport(
method_id=method_id, method_id=method_id,
namespace=namespace, namespace=namespace,
name=name, name=name,
host_interface=host_interface,
host_name=host_name, host_name=host_name,
sdk_name=sdk_name, sdk_name=sdk_name,
description=description, description=description,
@@ -132,15 +114,16 @@ def extension_api_method(
) )
def decorator( def decorator(
function: Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]], function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
) -> Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]: ) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]:
@wraps(function) @wraps(function)
async def wrapper(self: ExtensionAPI, request: _RequestModel) -> _ResponseModel: async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel:
if require_auth and not self.has_authenticated_context(): api = getattr(self, "api", self)
if require_auth and not api.has_authenticated_context():
raise PermissionError( raise PermissionError(
f"Extension API method '{method_id}' requires authentication." f"Extension API method '{method_id}' requires authentication."
) )
self.require_permission(required_permission) api.require_permission(required_permission)
return await function(self, request) return await function(self, request)
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export) setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
@@ -167,6 +150,9 @@ class ExtensionAPI:
self.context = context self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id self.owner_id = sha256s(user_id) if user_id else owner_id
self._uuid = secrets.token_urlsafe(12).replace("-", "_") self._uuid = secrets.token_urlsafe(12).replace("-", "_")
from .api_utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(self)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@@ -502,281 +488,6 @@ class ExtensionAPI:
log("extension:%s %s", self.extension_id, request.message) log("extension:%s %s", self.extension_id, request.message)
return LogResponse() return LogResponse()
@extension_api_method(
method_id="utils.currencies.list",
namespace="utils",
name="List currencies",
host_name="utils_currencies_list",
sdk_name="currenciesList",
description="List currencies supported by LNbits exchange-rate conversion.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_list(
self, request: EmptyRequest
) -> CurrencyListResponse:
from lnbits.utils.exchange_rates import allowed_currencies
return CurrencyListResponse(currencies=allowed_currencies())
@extension_api_method(
method_id="utils.currencies.rate",
namespace="utils",
name="Get currency rate",
host_name="utils_currencies_rate",
sdk_name="currenciesRate",
description="Get sats-per-fiat and BTC price for a currency.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_rate(
self, request: CurrencyRateRequest
) -> CurrencyRateResponse:
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
return CurrencyRateResponse(rate=rate, price=price)
@extension_api_method(
method_id="utils.currencies.convert",
namespace="utils",
name="Convert currency amount",
host_name="utils_currencies_convert",
sdk_name="currenciesConvert",
description="Convert between sats, BTC, and supported fiat currencies.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_convert(
self, request: CurrencyConvertRequest
) -> CurrencyConvertResponse:
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from_currency = request.from_currency
if from_currency == "sats":
from_currency = "sat"
amounts: list[tuple[str, float]] = []
if from_currency == "sat":
sats = int(request.amount)
amounts.append(("BTC", sats / 100_000_000))
amounts.append(("sats", sats))
for currency in request.to.split(","):
currency = currency.strip()
if currency:
amounts.append(
(
currency.upper(),
await satoshis_amount_as_fiat(sats, currency),
)
)
else:
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
amounts.append((from_currency.upper(), request.amount))
amounts.append(("sats", sats))
amounts.append(("BTC", sats / 100_000_000))
return CurrencyConvertResponse(amounts=amounts)
@extension_api_method(
method_id="utils.currencies.fiat_to_sats",
namespace="utils",
name="Convert fiat to sats",
host_name="utils_currencies_fiat_to_sats",
sdk_name="currenciesFiatToSats",
description="Convert a fiat amount to sats.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_fiat_to_sats(
self, request: FiatToSatsRequest
) -> FiatToSatsResponse:
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
return FiatToSatsResponse(
amount_sat=await fiat_amount_as_satoshis(
request.amount,
request.currency,
)
)
@extension_api_method(
method_id="utils.currencies.sats_to_fiat",
namespace="utils",
name="Convert sats to fiat",
host_name="utils_currencies_sats_to_fiat",
sdk_name="currenciesSatsToFiat",
description="Convert a sats amount to fiat.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_sats_to_fiat(
self, request: SatsToFiatRequest
) -> SatsToFiatResponse:
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
return SatsToFiatResponse(
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
)
@extension_api_method(
method_id="utils.server.health",
namespace="utils",
name="Server health",
host_name="utils_server_health",
sdk_name="serverHealth",
description="Return basic public LNbits server health data.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_server_health(self, request: EmptyRequest) -> ServerHealthResponse:
from lnbits.settings import settings
return ServerHealthResponse(
server_time=int(time.time()),
up_time=settings.lnbits_server_up_time,
)
@extension_api_method(
method_id="utils.lightning.decode_invoice",
namespace="utils",
name="Decode Lightning invoice",
host_name="utils_lightning_decode_invoice",
sdk_name="lightningDecodeInvoice",
description="Decode a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_decode_invoice(
self, request: Bolt11Request
) -> DecodeInvoiceResponse:
invoice = _decode_bolt11(request.bolt11)
return _decoded_invoice_response(invoice)
@extension_api_method(
method_id="utils.lightning.validate_invoice",
namespace="utils",
name="Validate Lightning invoice",
host_name="utils_lightning_validate_invoice",
sdk_name="lightningValidateInvoice",
description="Validate whether a string is a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_validate_invoice(
self, request: Bolt11Request
) -> ValidateInvoiceResponse:
try:
_decode_bolt11(request.bolt11)
return ValidateInvoiceResponse(valid=True)
except Exception as exc:
return ValidateInvoiceResponse(valid=False, error=str(exc))
@extension_api_method(
method_id="utils.lightning.invoice_payment_hash",
namespace="utils",
name="Get Lightning invoice payment hash",
host_name="utils_lightning_invoice_payment_hash",
sdk_name="lightningInvoicePaymentHash",
description="Get the payment hash from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_payment_hash(
self, request: Bolt11Request
) -> InvoicePaymentHashResponse:
return InvoicePaymentHashResponse(
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
)
@extension_api_method(
method_id="utils.lightning.invoice_amount_msat",
namespace="utils",
name="Get Lightning invoice amount",
host_name="utils_lightning_invoice_amount_msat",
sdk_name="lightningInvoiceAmountMsat",
description="Get the amount in msat from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_amount_msat(
self, request: Bolt11Request
) -> InvoiceAmountMsatResponse:
return InvoiceAmountMsatResponse(
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_expiry",
namespace="utils",
name="Get Lightning invoice expiry",
host_name="utils_lightning_invoice_expiry",
sdk_name="lightningInvoiceExpiry",
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_expiry(
self, request: Bolt11Request
) -> InvoiceExpiryResponse:
return InvoiceExpiryResponse(
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_memo",
namespace="utils",
name="Get Lightning invoice memo",
host_name="utils_lightning_invoice_memo",
sdk_name="lightningInvoiceMemo",
description="Get the memo from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_memo(
self, request: Bolt11Request
) -> InvoiceMemoResponse:
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
@extension_api_method(
method_id="utils.lightning.verify_preimage",
namespace="utils",
name="Verify Lightning preimage",
host_name="utils_lightning_verify_preimage",
sdk_name="lightningVerifyPreimage",
description="Verify that a preimage matches a payment hash.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_verify_preimage(
self, request: VerifyPreimageRequest
) -> VerifyPreimageResponse:
from lnbits.utils.crypto import verify_preimage
return VerifyPreimageResponse(
valid=verify_preimage(request.preimage, request.payment_hash)
)
@extension_api_method(
method_id="utils.lightning.random_secret_and_hash",
namespace="utils",
name="Random Lightning secret and hash",
host_name="utils_lightning_random_secret_and_hash",
sdk_name="lightningRandomSecretAndHash",
description="Create a random secret and matching SHA256 hash.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_random_secret_and_hash(
self, request: RandomSecretAndHashRequest
) -> RandomSecretAndHashResponse:
from lnbits.utils.crypto import random_secret_and_hash
secret, payment_hash = random_secret_and_hash(request.length)
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
@staticmethod @staticmethod
def _permission_data( def _permission_data(
permissions: Iterable[Any], permissions: Iterable[Any],
@@ -846,85 +557,49 @@ class ExtensionAPI:
return table, wallet_field return table, wallet_field
def _decode_bolt11(payment_request: str) -> Any:
from lnbits import bolt11
return bolt11.decode(payment_request)
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
return DecodeInvoiceResponse(
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
amount_msat=_invoice_amount_msat(invoice),
expiry=_invoice_expiry(invoice),
expires_at=_invoice_expires_at(invoice),
memo=_invoice_memo(invoice),
)
def _invoice_amount_msat(invoice: Any) -> int | None:
amount_msat = getattr(invoice, "amount_msat", None)
if amount_msat is None:
return None
return int(amount_msat)
def _invoice_expiry(invoice: Any) -> int | None:
expiry = getattr(invoice, "expiry", None)
if expiry is None:
return None
return int(expiry)
def _invoice_expires_at(invoice: Any) -> int | None:
expiry_date = getattr(invoice, "expiry_date", None)
if isinstance(expiry_date, datetime):
return int(expiry_date.timestamp())
date = getattr(invoice, "date", None)
expiry = getattr(invoice, "expiry", None)
if isinstance(date, datetime) and expiry is not None:
return int(date.timestamp() + int(expiry))
if isinstance(date, (int, float)) and expiry is not None:
return int(date + int(expiry))
return None
def _invoice_memo(invoice: Any) -> str | None:
memo = getattr(invoice, "description", None)
return str(memo) if memo is not None else None
def list_extension_api_methods( def list_extension_api_methods(
api_cls: type[ExtensionAPI] = ExtensionAPI, api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> list[ExtensionAPIMethod]: ) -> list[ExtensionAPIMethod]:
methods: list[ExtensionAPIMethod] = [] methods: list[ExtensionAPIMethod] = []
for python_name, function in inspect.getmembers(api_cls, inspect.isfunction): for prefix, method_cls in _extension_api_method_sources(api_cls):
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None) for python_name, function in inspect.getmembers(method_cls, inspect.isfunction):
if not export: export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
continue if not export:
continue
request_model, response_model = _get_method_models(function) request_model, response_model = _get_method_models(function)
methods.append( methods.append(
ExtensionAPIMethod( ExtensionAPIMethod(
method_id=export.method_id, method_id=export.method_id,
namespace=export.namespace, namespace=export.namespace,
name=export.name, name=export.name,
python_name=python_name, python_name=f"{prefix}.{python_name}" if prefix else python_name,
host_name=export.host_name, host_interface=export.host_interface,
sdk_name=export.sdk_name, host_name=export.host_name,
description=export.description, sdk_name=export.sdk_name,
request_model=request_model, description=export.description,
response_model=response_model, request_model=request_model,
required_permission=export.required_permission, response_model=response_model,
require_auth=export.require_auth, required_permission=export.required_permission,
require_auth=export.require_auth,
)
) )
)
return sorted(methods, key=lambda method: method.method_id) return sorted(methods, key=lambda method: method.method_id)
def _extension_api_method_sources(
api_cls: type[ExtensionAPI],
) -> list[tuple[str, type[Any]]]:
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
if issubclass(api_cls, ExtensionAPI):
from .api_utils import extension_api_utils_method_classes
sources.extend(extension_api_utils_method_classes().items())
return sources
def extension_api_permission_ids( def extension_api_permission_ids(
api_cls: type[ExtensionAPI] = ExtensionAPI, api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> set[str]: ) -> set[str]:
@@ -956,6 +631,7 @@ def extension_api_contract(
"namespace": method.namespace, "namespace": method.namespace,
"name": method.name, "name": method.name,
"python_name": method.python_name, "python_name": method.python_name,
"host_interface": method.host_interface,
"host_name": method.host_name, "host_name": method.host_name,
"sdk_name": method.sdk_name, "sdk_name": method.sdk_name,
"sdk_qualified_name": method.sdk_qualified_name, "sdk_qualified_name": method.sdk_qualified_name,
+381
View File
@@ -0,0 +1,381 @@
from __future__ import annotations
import time
from datetime import datetime
from typing import TYPE_CHECKING, Any
from .api import extension_api_method
from .models import (
Bolt11Request,
CurrencyConvertRequest,
CurrencyConvertResponse,
CurrencyListResponse,
CurrencyRateRequest,
CurrencyRateResponse,
DecodeInvoiceResponse,
EmptyRequest,
FiatToSatsRequest,
FiatToSatsResponse,
InvoiceAmountMsatResponse,
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
SatsToFiatResponse,
ServerHealthResponse,
ValidateInvoiceResponse,
VerifyPreimageRequest,
VerifyPreimageResponse,
)
if TYPE_CHECKING:
from .api import ExtensionAPI
class ExtensionAPIUtils:
def __init__(self, api: ExtensionAPI) -> None:
self.api = api
self.currencies = ExtensionCurrencyUtils(api)
self.server = ExtensionServerUtils(api)
self.lightning = ExtensionLightningUtils(api)
class _ExtensionAPIUtilsGroup:
def __init__(self, api: ExtensionAPI) -> None:
self.api = api
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.currencies.list",
namespace="utils.currencies",
name="List currencies",
host_interface="utils-currencies",
host_name="list_currencies",
sdk_name="list",
description="List currencies supported by LNbits exchange-rate conversion.",
required_permission="utils.basic",
require_auth=False,
)
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
from lnbits.utils.exchange_rates import allowed_currencies
return CurrencyListResponse(currencies=allowed_currencies())
@extension_api_method(
method_id="utils.currencies.rate",
namespace="utils.currencies",
name="Get currency rate",
host_interface="utils-currencies",
host_name="rate",
sdk_name="rate",
description="Get sats-per-fiat and BTC price for a currency.",
required_permission="utils.basic",
require_auth=False,
)
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
return CurrencyRateResponse(rate=rate, price=price)
@extension_api_method(
method_id="utils.currencies.convert",
namespace="utils.currencies",
name="Convert currency amount",
host_interface="utils-currencies",
host_name="convert",
sdk_name="convert",
description="Convert between sats, BTC, and supported fiat currencies.",
required_permission="utils.basic",
require_auth=False,
)
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from_currency = request.from_currency
if from_currency == "sats":
from_currency = "sat"
amounts: list[tuple[str, float]] = []
if from_currency == "sat":
sats = int(request.amount)
amounts.append(("BTC", sats / 100_000_000))
amounts.append(("sats", sats))
for currency in request.to.split(","):
currency = currency.strip()
if currency:
amounts.append(
(
currency.upper(),
await satoshis_amount_as_fiat(sats, currency),
)
)
else:
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
amounts.append((from_currency.upper(), request.amount))
amounts.append(("sats", sats))
amounts.append(("BTC", sats / 100_000_000))
return CurrencyConvertResponse(amounts=amounts)
@extension_api_method(
method_id="utils.currencies.fiat_to_sats",
namespace="utils.currencies",
name="Convert fiat to sats",
host_interface="utils-currencies",
host_name="fiat_to_sats",
sdk_name="fiatToSats",
description="Convert a fiat amount to sats.",
required_permission="utils.basic",
require_auth=False,
)
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
return FiatToSatsResponse(
amount_sat=await fiat_amount_as_satoshis(
request.amount,
request.currency,
)
)
@extension_api_method(
method_id="utils.currencies.sats_to_fiat",
namespace="utils.currencies",
name="Convert sats to fiat",
host_interface="utils-currencies",
host_name="sats_to_fiat",
sdk_name="satsToFiat",
description="Convert a sats amount to fiat.",
required_permission="utils.basic",
require_auth=False,
)
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
return SatsToFiatResponse(
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
)
class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.server.health",
namespace="utils.server",
name="Server health",
host_interface="utils-server",
host_name="health",
sdk_name="health",
description="Return basic public LNbits server health data.",
required_permission="utils.basic",
require_auth=False,
)
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
from lnbits.settings import settings
return ServerHealthResponse(
server_time=int(time.time()),
up_time=settings.lnbits_server_up_time,
)
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lightning.decode_invoice",
namespace="utils.lightning",
name="Decode Lightning invoice",
host_interface="utils-lightning",
host_name="decode_invoice",
sdk_name="decodeInvoice",
description="Decode a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse:
invoice = _decode_bolt11(request.bolt11)
return _decoded_invoice_response(invoice)
@extension_api_method(
method_id="utils.lightning.validate_invoice",
namespace="utils.lightning",
name="Validate Lightning invoice",
host_interface="utils-lightning",
host_name="validate_invoice",
sdk_name="validateInvoice",
description="Validate whether a string is a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse:
try:
_decode_bolt11(request.bolt11)
return ValidateInvoiceResponse(valid=True)
except Exception as exc:
return ValidateInvoiceResponse(valid=False, error=str(exc))
@extension_api_method(
method_id="utils.lightning.invoice_payment_hash",
namespace="utils.lightning",
name="Get Lightning invoice payment hash",
host_interface="utils-lightning",
host_name="invoice_payment_hash",
sdk_name="invoicePaymentHash",
description="Get the payment hash from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_payment_hash(
self, request: Bolt11Request
) -> InvoicePaymentHashResponse:
return InvoicePaymentHashResponse(
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
)
@extension_api_method(
method_id="utils.lightning.invoice_amount_msat",
namespace="utils.lightning",
name="Get Lightning invoice amount",
host_interface="utils-lightning",
host_name="invoice_amount_msat",
sdk_name="invoiceAmountMsat",
description="Get the amount in msat from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_amount_msat(
self, request: Bolt11Request
) -> InvoiceAmountMsatResponse:
return InvoiceAmountMsatResponse(
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_expiry",
namespace="utils.lightning",
name="Get Lightning invoice expiry",
host_interface="utils-lightning",
host_name="invoice_expiry",
sdk_name="invoiceExpiry",
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse:
return InvoiceExpiryResponse(
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_memo",
namespace="utils.lightning",
name="Get Lightning invoice memo",
host_interface="utils-lightning",
host_name="invoice_memo",
sdk_name="invoiceMemo",
description="Get the memo from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse:
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
@extension_api_method(
method_id="utils.lightning.verify_preimage",
namespace="utils.lightning",
name="Verify Lightning preimage",
host_interface="utils-lightning",
host_name="verify_preimage",
sdk_name="verifyPreimage",
description="Verify that a preimage matches a payment hash.",
required_permission="utils.basic",
require_auth=False,
)
async def verify_preimage(
self, request: VerifyPreimageRequest
) -> VerifyPreimageResponse:
from lnbits.utils.crypto import verify_preimage
return VerifyPreimageResponse(
valid=verify_preimage(request.preimage, request.payment_hash)
)
@extension_api_method(
method_id="utils.lightning.random_secret_and_hash",
namespace="utils.lightning",
name="Random Lightning secret and hash",
host_interface="utils-lightning",
host_name="random_secret_and_hash",
sdk_name="randomSecretAndHash",
description="Create a random secret and matching SHA256 hash.",
required_permission="utils.basic",
require_auth=False,
)
async def random_secret_and_hash(
self, request: RandomSecretAndHashRequest
) -> RandomSecretAndHashResponse:
from lnbits.utils.crypto import random_secret_and_hash
secret, payment_hash = random_secret_and_hash(request.length)
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]:
return {
"utils.currencies": ExtensionCurrencyUtils,
"utils.server": ExtensionServerUtils,
"utils.lightning": ExtensionLightningUtils,
}
def _decode_bolt11(payment_request: str) -> Any:
from lnbits import bolt11
return bolt11.decode(payment_request)
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
return DecodeInvoiceResponse(
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
amount_msat=_invoice_amount_msat(invoice),
expiry=_invoice_expiry(invoice),
expires_at=_invoice_expires_at(invoice),
memo=_invoice_memo(invoice),
)
def _invoice_amount_msat(invoice: Any) -> int | None:
amount_msat = getattr(invoice, "amount_msat", None)
if amount_msat is None:
return None
return int(amount_msat)
def _invoice_expiry(invoice: Any) -> int | None:
expiry = getattr(invoice, "expiry", None)
if expiry is None:
return None
return int(expiry)
def _invoice_expires_at(invoice: Any) -> int | None:
expiry_date = getattr(invoice, "expiry_date", None)
if isinstance(expiry_date, datetime):
return int(expiry_date.timestamp())
date = getattr(invoice, "date", None)
expiry = getattr(invoice, "expiry", None)
if isinstance(date, datetime) and expiry is not None:
return int(date.timestamp() + int(expiry))
if isinstance(date, (int, float)) and expiry is not None:
return int(date + int(expiry))
return None
def _invoice_memo(invoice: Any) -> str | None:
memo = getattr(invoice, "description", None)
return str(memo) if memo is not None else None
+21 -5
View File
@@ -30,26 +30,34 @@ class ExtensionAPIHost:
) -> dict[str, Any]: ) -> dict[str, Any]:
method = self._require_method(host_name) method = self._require_method(host_name)
request = self._request_model(method, payload) request = self._request_model(method, payload)
handler = getattr(self.api, method.python_name) handler = _resolve_attr_path(self.api, method.python_name)
response = handler(request) response = handler(request)
if inspect.isawaitable(response): if inspect.isawaitable(response):
response = await response response = await response
return self._response_payload(method, response) return self._response_payload(method, response)
def imports(self) -> dict[str, HostImport]: def imports(self) -> dict[str, HostImport]:
return self.imports_for_interface("host")
def import_object(self) -> dict[str, dict[str, HostImport]]:
interfaces = sorted({method.host_interface for method in self.methods})
return {
f"lnbits:extension/{interface}": self.imports_for_interface(interface)
for interface in interfaces
}
def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]:
return { return {
_snake_to_camel(method.host_name): self._make_import(method) _snake_to_camel(method.host_name): self._make_import(method)
for method in self.methods for method in self.methods
if method.host_interface == host_interface
} }
def import_object(self) -> dict[str, dict[str, HostImport]]:
return {"lnbits:extension/host": self.imports()}
def _make_import(self, method: ExtensionAPIMethod) -> HostImport: def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
async def host_import( async def host_import(
payload: Mapping[str, Any] | BaseModel | None = None, payload: Mapping[str, Any] | BaseModel | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.invoke(method.host_name, payload) return await self.invoke(method.method_id, payload)
return host_import return host_import
@@ -66,6 +74,8 @@ class ExtensionAPIHost:
index: dict[str, ExtensionAPIMethod] = {} index: dict[str, ExtensionAPIMethod] = {}
for method in methods: for method in methods:
for host_name in { for host_name in {
method.method_id,
f"{method.host_interface}:{method.host_name}",
method.host_name, method.host_name,
_snake_to_camel(method.host_name), _snake_to_camel(method.host_name),
method.host_name.replace("_", "-"), method.host_name.replace("_", "-"),
@@ -118,3 +128,9 @@ def _snake_to_camel(value: str) -> str:
def _to_snake(value: str) -> str: def _to_snake(value: str) -> str:
value = value.replace("-", "_") value = value.replace("-", "_")
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower() return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
def _resolve_attr_path(value: Any, path: str) -> Any:
for part in path.split("."):
value = getattr(value, part)
return value
+11 -6
View File
@@ -128,12 +128,17 @@ def _add_extension_host_imports(
event_loop: asyncio.AbstractEventLoop, event_loop: asyncio.AbstractEventLoop,
) -> None: ) -> None:
with linker.root() as root: with linker.root() as root:
with root.add_instance("lnbits:extension/host") as host: methods_by_interface: dict[str, list[Any]] = {}
for method in list_extension_api_methods(): for method in list_extension_api_methods():
host.add_func( methods_by_interface.setdefault(method.host_interface, []).append(method)
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.host_name, event_loop), for host_interface, methods in methods_by_interface.items():
) with root.add_instance(f"lnbits:extension/{host_interface}") as host:
for method in methods:
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.method_id, event_loop),
)
def _make_host_import( def _make_host_import(
+103 -45
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import re
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
@@ -185,6 +186,7 @@ def _render_method_metadata(
f' namespace: "{method.namespace}",', f' namespace: "{method.namespace}",',
f' sdkName: "{method.sdk_name}",', f' sdkName: "{method.sdk_name}",',
f' pythonName: "{method.python_name}",', f' pythonName: "{method.python_name}",',
f' hostInterface: "{method.host_interface}",',
f' hostName: "{method.host_name}",', f' hostName: "{method.host_name}",',
f' hostJsName: "{_camel(method.host_name)}",', f' hostJsName: "{_camel(method.host_name)}",',
f" requiredPermission: {permission},", f" requiredPermission: {permission},",
@@ -197,76 +199,120 @@ def _render_method_metadata(
def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]: def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
lines = ["export type ExtensionHost = {"] lines = ["export type ExtensionHost = {"]
for method in sorted(methods, key=lambda item: item.host_name): for host_interface, interface_methods in _methods_by_host_interface(
request = _model_name(method.request_model) methods
response = _model_name(method.response_model) ).items():
if _is_empty_model(method.request_model): lines.append(f" {_ts_property(host_interface)}: {{")
lines.append(f" {_camel(method.host_name)}(): MaybePromise<{response}>") for method in sorted(interface_methods, key=lambda item: item.host_name):
else:
lines.append(
f" {_camel(method.host_name)}"
f"(input: {request}): MaybePromise<{response}>"
)
lines.append("}")
return lines
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = ["export type ExtensionSdk = {"]
for namespace, namespace_methods in namespaces.items():
lines.append(f" {namespace}: {{")
for method in namespace_methods:
request = _model_name(method.request_model) request = _model_name(method.request_model)
response = _model_name(method.response_model) response = _model_name(method.response_model)
if _is_empty_model(method.request_model): if _is_empty_model(method.request_model):
lines.append(f" {method.sdk_name}(): Promise<{response}>") lines.append(
f" {_camel(method.host_name)}(): MaybePromise<{response}>"
)
else: else:
lines.append( lines.append(
f" {method.sdk_name}(input: {request}): Promise<{response}>" f" {_camel(method.host_name)}"
f"(input: {request}): MaybePromise<{response}>"
) )
lines.append(" }") lines.append(" }")
lines.append("}") lines.append("}")
return lines return lines
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
lines = ["export type ExtensionSdk = {"]
_render_sdk_type_node(lines, _namespace_tree(methods), 1)
lines.append("}")
return lines
def _render_create_sdk(methods: Sequence[ExtensionAPIMethod]) -> list[str]: def _render_create_sdk(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = [ lines = [
"export function createExtensionSdk(", "export function createExtensionSdk(",
" host: ExtensionHost", " host: ExtensionHost",
"): ExtensionSdk {", "): ExtensionSdk {",
" return {", " return {",
] ]
for namespace, namespace_methods in namespaces.items(): _render_create_sdk_node(lines, _namespace_tree(methods), 2)
lines.append(f" {namespace}: {{")
for method in namespace_methods:
host_name = _camel(method.host_name)
if _is_empty_model(method.request_model):
signature = f"{method.sdk_name}()"
host_call = f"host.{host_name}()"
else:
signature = f"{method.sdk_name}(input)"
host_call = f"host.{host_name}(input)"
lines.extend(
[
f" async {signature} {{",
f" return {host_call}",
" },",
]
)
lines.append(" },")
lines.extend([" }", "}"]) lines.extend([" }", "}"])
return lines return lines
def _methods_by_namespace( def _render_sdk_type_node(lines: list[str], node: dict[str, Any], level: int) -> None:
indent = " " * level
for namespace, child in _iter_child_namespaces(node):
lines.append(f"{indent}{namespace}: {{")
_render_sdk_type_node(lines, child, level + 1)
lines.append(f"{indent}}}")
for method in node.get("__methods__", []):
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f"{indent}{method.sdk_name}(): Promise<{response}>")
else:
lines.append(
f"{indent}{method.sdk_name}(input: {request}): Promise<{response}>"
)
def _render_create_sdk_node(lines: list[str], node: dict[str, Any], level: int) -> None:
indent = " " * level
for namespace, child in _iter_child_namespaces(node):
lines.append(f"{indent}{namespace}: {{")
_render_create_sdk_node(lines, child, level + 1)
lines.append(f"{indent}}},")
for method in node.get("__methods__", []):
host_call_target = (
f"host{_ts_access(method.host_interface)}"
f"{_ts_access(_camel(method.host_name))}"
)
if _is_empty_model(method.request_model):
signature = f"{method.sdk_name}()"
host_call = f"{host_call_target}()"
else:
signature = f"{method.sdk_name}(input)"
host_call = f"{host_call_target}(input)"
lines.extend(
[
f"{indent}async {signature} {{",
f"{indent} return {host_call}",
f"{indent}}},",
]
)
def _methods_by_host_interface(
methods: Sequence[ExtensionAPIMethod], methods: Sequence[ExtensionAPIMethod],
) -> dict[str, list[ExtensionAPIMethod]]: ) -> dict[str, list[ExtensionAPIMethod]]:
namespaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list) interfaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list)
for method in sorted(
methods, key=lambda item: (item.host_interface, item.host_name)
):
interfaces[method.host_interface].append(method)
return dict(sorted(interfaces.items()))
def _namespace_tree(methods: Sequence[ExtensionAPIMethod]) -> dict[str, Any]:
tree: dict[str, Any] = {}
for method in sorted(methods, key=lambda item: (item.namespace, item.sdk_name)): for method in sorted(methods, key=lambda item: (item.namespace, item.sdk_name)):
namespaces[method.namespace].append(method) node = tree
return dict(sorted(namespaces.items())) for part in method.namespace.split("."):
node = node.setdefault(part, {})
node.setdefault("__methods__", []).append(method)
return tree
def _iter_child_namespaces(
node: dict[str, Any],
) -> list[tuple[str, dict[str, Any]]]:
return [
(key, value)
for key, value in sorted(node.items())
if key != "__methods__" and isinstance(value, dict)
]
def _model_name(model: type[BaseModel]) -> str: def _model_name(model: type[BaseModel]) -> str:
@@ -278,6 +324,18 @@ def _camel(value: str) -> str:
return head + "".join(part.capitalize() for part in tail) return head + "".join(part.capitalize() for part in tail)
def _ts_property(value: str) -> str:
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
return value
return f'"{value}"'
def _ts_access(value: str) -> str:
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
return f".{value}"
return f'["{value}"]'
def _is_empty_model(model: type[BaseModel]) -> bool: def _is_empty_model(model: type[BaseModel]) -> bool:
return not model.__fields__ return not model.__fields__