From 6ad5cca4f40a8bd6983a14aea896757050df7c52 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Jul 2026 11:21:39 +0300 Subject: [PATCH] refactor: better namespace --- lnbits/core/extensions/api.py | 420 +++------------------- lnbits/core/extensions/api_utils.py | 381 ++++++++++++++++++++ lnbits/core/extensions/runtime.py | 26 +- lnbits/core/extensions/wasm.py | 17 +- tools/codegen/extension_sdk_typescript.py | 148 +++++--- 5 files changed, 564 insertions(+), 428 deletions(-) create mode 100644 lnbits/core/extensions/api_utils.py diff --git a/lnbits/core/extensions/api.py b/lnbits/core/extensions/api.py index 4c9fcaeef..2ca800c68 100644 --- a/lnbits/core/extensions/api.py +++ b/lnbits/core/extensions/api.py @@ -7,7 +7,6 @@ import secrets import time from collections.abc import Awaitable, Callable, Iterable, Mapping from dataclasses import dataclass -from datetime import datetime from functools import wraps from typing import Any, TypeVar, cast, get_type_hints @@ -16,37 +15,19 @@ from pydantic import BaseModel from lnbits.helpers import sha256s from .models import ( - Bolt11Request, CreateInvoicePublicRequest, CreateInvoiceRequest, CreateInvoiceResponse, - CurrencyConvertRequest, - CurrencyConvertResponse, - CurrencyListResponse, - CurrencyRateRequest, - CurrencyRateResponse, - DecodeInvoiceResponse, EmptyRequest, ExtensionApiRequest, - FiatToSatsRequest, - FiatToSatsResponse, HttpRequest, HttpResponse, - InvoiceAmountMsatResponse, - InvoiceExpiryResponse, - InvoiceMemoResponse, - InvoicePaymentHashResponse, ListUserWalletsResponse, LogRequest, LogResponse, NowResponse, RandomIdRequest, RandomIdResponse, - RandomSecretAndHashRequest, - RandomSecretAndHashResponse, - SatsToFiatRequest, - SatsToFiatResponse, - ServerHealthResponse, StorageDeleteRequest, StorageDeleteResponse, StorageGetRequest, @@ -56,9 +37,6 @@ from .models import ( StorageSetRequest, StorageSetResponse, UserWalletSummary, - ValidateInvoiceResponse, - VerifyPreimageRequest, - VerifyPreimageResponse, ) from .storage import ( storage_delete_row, @@ -80,6 +58,7 @@ class ExtensionAPIMethodExport: method_id: str namespace: str name: str + host_interface: str host_name: str sdk_name: str description: str @@ -93,6 +72,7 @@ class ExtensionAPIMethod: namespace: str name: str python_name: str + host_interface: str host_name: str sdk_name: str description: str @@ -114,16 +94,18 @@ def extension_api_method( host_name: str, sdk_name: str, description: str, + host_interface: str = "host", required_permission: str | None = None, require_auth: bool = True, ) -> Callable[ - [Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]], - Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]], + [Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]], + Callable[[Any, _RequestModel], Awaitable[_ResponseModel]], ]: export = ExtensionAPIMethodExport( method_id=method_id, namespace=namespace, name=name, + host_interface=host_interface, host_name=host_name, sdk_name=sdk_name, description=description, @@ -132,15 +114,16 @@ def extension_api_method( ) def decorator( - function: Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]], - ) -> Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]: + function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]], + ) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]: @wraps(function) - async def wrapper(self: ExtensionAPI, request: _RequestModel) -> _ResponseModel: - if require_auth and not self.has_authenticated_context(): + async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel: + api = getattr(self, "api", self) + if require_auth and not api.has_authenticated_context(): raise PermissionError( f"Extension API method '{method_id}' requires authentication." ) - self.require_permission(required_permission) + api.require_permission(required_permission) return await function(self, request) setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export) @@ -167,6 +150,9 @@ class ExtensionAPI: self.context = context self.owner_id = sha256s(user_id) if user_id else owner_id self._uuid = secrets.token_urlsafe(12).replace("-", "_") + from .api_utils import ExtensionAPIUtils + + self.utils = ExtensionAPIUtils(self) def __repr__(self) -> str: return ( @@ -502,281 +488,6 @@ class ExtensionAPI: log("extension:%s %s", self.extension_id, request.message) 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 def _permission_data( permissions: Iterable[Any], @@ -846,85 +557,49 @@ class ExtensionAPI: 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( api_cls: type[ExtensionAPI] = ExtensionAPI, ) -> list[ExtensionAPIMethod]: methods: list[ExtensionAPIMethod] = [] - for python_name, function in inspect.getmembers(api_cls, inspect.isfunction): - export = getattr(function, _EXTENSION_API_METHOD_ATTR, None) - if not export: - continue + for prefix, method_cls in _extension_api_method_sources(api_cls): + for python_name, function in inspect.getmembers(method_cls, inspect.isfunction): + export = getattr(function, _EXTENSION_API_METHOD_ATTR, None) + if not export: + continue - request_model, response_model = _get_method_models(function) - methods.append( - ExtensionAPIMethod( - method_id=export.method_id, - namespace=export.namespace, - name=export.name, - python_name=python_name, - host_name=export.host_name, - sdk_name=export.sdk_name, - description=export.description, - request_model=request_model, - response_model=response_model, - required_permission=export.required_permission, - require_auth=export.require_auth, + request_model, response_model = _get_method_models(function) + methods.append( + ExtensionAPIMethod( + method_id=export.method_id, + namespace=export.namespace, + name=export.name, + python_name=f"{prefix}.{python_name}" if prefix else python_name, + host_interface=export.host_interface, + host_name=export.host_name, + sdk_name=export.sdk_name, + description=export.description, + request_model=request_model, + response_model=response_model, + required_permission=export.required_permission, + require_auth=export.require_auth, + ) ) - ) 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( api_cls: type[ExtensionAPI] = ExtensionAPI, ) -> set[str]: @@ -956,6 +631,7 @@ def extension_api_contract( "namespace": method.namespace, "name": method.name, "python_name": method.python_name, + "host_interface": method.host_interface, "host_name": method.host_name, "sdk_name": method.sdk_name, "sdk_qualified_name": method.sdk_qualified_name, diff --git a/lnbits/core/extensions/api_utils.py b/lnbits/core/extensions/api_utils.py new file mode 100644 index 000000000..25f7f8edb --- /dev/null +++ b/lnbits/core/extensions/api_utils.py @@ -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 diff --git a/lnbits/core/extensions/runtime.py b/lnbits/core/extensions/runtime.py index 1ff2ca54f..19db9e386 100644 --- a/lnbits/core/extensions/runtime.py +++ b/lnbits/core/extensions/runtime.py @@ -30,26 +30,34 @@ class ExtensionAPIHost: ) -> dict[str, Any]: method = self._require_method(host_name) 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) if inspect.isawaitable(response): response = await response return self._response_payload(method, response) 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 { _snake_to_camel(method.host_name): self._make_import(method) 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: async def host_import( payload: Mapping[str, Any] | BaseModel | None = None, ) -> dict[str, Any]: - return await self.invoke(method.host_name, payload) + return await self.invoke(method.method_id, payload) return host_import @@ -66,6 +74,8 @@ class ExtensionAPIHost: index: dict[str, ExtensionAPIMethod] = {} for method in methods: for host_name in { + method.method_id, + f"{method.host_interface}:{method.host_name}", method.host_name, _snake_to_camel(method.host_name), method.host_name.replace("_", "-"), @@ -118,3 +128,9 @@ def _snake_to_camel(value: str) -> str: def _to_snake(value: str) -> str: value = value.replace("-", "_") 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 diff --git a/lnbits/core/extensions/wasm.py b/lnbits/core/extensions/wasm.py index 66ab198c3..8189af89e 100644 --- a/lnbits/core/extensions/wasm.py +++ b/lnbits/core/extensions/wasm.py @@ -128,12 +128,17 @@ def _add_extension_host_imports( event_loop: asyncio.AbstractEventLoop, ) -> None: with linker.root() as root: - with root.add_instance("lnbits:extension/host") as host: - for method in list_extension_api_methods(): - host.add_func( - method.host_name.replace("_", "-"), - _make_host_import(api_host, method.host_name, event_loop), - ) + methods_by_interface: dict[str, list[Any]] = {} + for method in list_extension_api_methods(): + methods_by_interface.setdefault(method.host_interface, []).append(method) + + 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( diff --git a/tools/codegen/extension_sdk_typescript.py b/tools/codegen/extension_sdk_typescript.py index 84256bfdb..a15b12b2c 100644 --- a/tools/codegen/extension_sdk_typescript.py +++ b/tools/codegen/extension_sdk_typescript.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import re from collections import defaultdict from collections.abc import Sequence from pathlib import Path @@ -185,6 +186,7 @@ def _render_method_metadata( f' namespace: "{method.namespace}",', f' sdkName: "{method.sdk_name}",', f' pythonName: "{method.python_name}",', + f' hostInterface: "{method.host_interface}",', f' hostName: "{method.host_name}",', f' hostJsName: "{_camel(method.host_name)}",', f" requiredPermission: {permission},", @@ -197,76 +199,120 @@ def _render_method_metadata( def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]: lines = ["export type ExtensionHost = {"] - for method in sorted(methods, key=lambda item: item.host_name): - request = _model_name(method.request_model) - response = _model_name(method.response_model) - if _is_empty_model(method.request_model): - lines.append(f" {_camel(method.host_name)}(): MaybePromise<{response}>") - 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: + for host_interface, interface_methods in _methods_by_host_interface( + methods + ).items(): + lines.append(f" {_ts_property(host_interface)}: {{") + for method in sorted(interface_methods, key=lambda item: item.host_name): request = _model_name(method.request_model) response = _model_name(method.response_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: lines.append( - f" {method.sdk_name}(input: {request}): Promise<{response}>" + f" {_camel(method.host_name)}" + f"(input: {request}): MaybePromise<{response}>" ) lines.append(" }") lines.append("}") 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]: - namespaces = _methods_by_namespace(methods) lines = [ "export function createExtensionSdk(", " host: ExtensionHost", "): ExtensionSdk {", " return {", ] - for namespace, namespace_methods in namespaces.items(): - 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(" },") + _render_create_sdk_node(lines, _namespace_tree(methods), 2) lines.extend([" }", "}"]) 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], ) -> 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)): - namespaces[method.namespace].append(method) - return dict(sorted(namespaces.items())) + node = tree + 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: @@ -278,6 +324,18 @@ def _camel(value: str) -> str: 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: return not model.__fields__