diff --git a/.env.example b/.env.example index 6ef60bc1..0a5e2bff 100644 --- a/.env.example +++ b/.env.example @@ -34,13 +34,17 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" -# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, +# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet # LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. # Set one of these blocks depending on the wallet kind you chose above: + +# ClicheWallet +CLICHE_ENDPOINT=ws://127.0.0.1:12000 + # SparkWallet SPARK_URL=http://localhost:9737/rpc SPARK_TOKEN=myaccesstoken diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 8a2ca1a5..3ce2c30f 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa +from .cliche import ClicheWallet from .clightning import CLightningWallet from .eclair import EclairWallet from .fake import FakeWallet diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py new file mode 100644 index 00000000..bc5c566c --- /dev/null +++ b/lnbits/wallets/cliche.py @@ -0,0 +1,141 @@ +import asyncio +import json +from os import getenv +from typing import AsyncGenerator, Dict, Optional + +import httpx +from loguru import logger +from websocket import create_connection + +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) + + +class ClicheWallet(Wallet): + """https://github.com/fiatjaf/cliche""" + + def __init__(self): + self.endpoint = getenv("CLICHE_ENDPOINT") + + async def status(self) -> StatusResponse: + try: + ws = create_connection(self.endpoint) + ws.send("get-info") + r = ws.recv() + except Exception as exc: + return StatusResponse( + f"Failed to connect to {self.endpoint} due to: {exc}", 0 + ) + try: + data = json.loads(r) + except: + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) + + return StatusResponse(None, data["result"]["wallets"][0]["balance"]) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> InvoiceResponse: + if description_hash: + ws = create_connection(self.endpoint) + ws.send( + f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash.hex()}" + ) + r = ws.recv() + else: + ws = create_connection(self.endpoint) + ws.send(f"create-invoice --msatoshi {amount*1000} --description {memo}") + r = ws.recv() + data = json.loads(r) + checking_id = None + payment_request = None + error_message = None + + if data.get("error") is not None and data["error"].get("message"): + logger.error(data["error"]["message"]) + error_message = data["error"]["message"] + return InvoiceResponse(False, checking_id, payment_request, error_message) + + if data.get("result") is not None: + checking_id, payment_request = ( + data["result"]["payment_hash"], + data["result"]["invoice"], + ) + else: + return InvoiceResponse( + False, checking_id, payment_request, "Could not get payment hash" + ) + + return InvoiceResponse(True, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + ws = create_connection(self.endpoint) + ws.send(f"pay-invoice --invoice {bolt11}") + r = ws.recv() + data = json.loads(r) + checking_id = None + error_message = None + + if data.get("error") is not None and data["error"].get("message"): + logger.error(data["error"]["message"]) + error_message = data["error"]["message"] + return PaymentResponse(False, None, 0, error_message) + + if data.get("result") is not None and data["result"].get("payment_hash"): + checking_id = data["result"]["payment_hash"] + else: + return PaymentResponse(False, checking_id, 0, "Could not get payment hash") + + return PaymentResponse(True, checking_id, 0, error_message) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + ws = create_connection(self.endpoint) + ws.send(f"check-payment --hash {checking_id}") + r = ws.recv() + data = json.loads(r) + + if data.get("error") is not None and data["error"].get("message"): + logger.error(data["error"]["message"]) + return PaymentStatus(None) + + statuses = {"pending": None, "complete": True, "failed": False} + return PaymentStatus(statuses[data["result"]["status"]]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + ws = create_connection(self.endpoint) + ws.send(f"check-payment --hash {checking_id}") + r = ws.recv() + data = json.loads(r) + + if data.get("error") is not None and data["error"].get("message"): + logger.error(data["error"]["message"]) + return PaymentStatus(None) + + statuses = {"pending": None, "complete": True, "failed": False} + return PaymentStatus(statuses[data["result"]["status"]]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + try: + ws = await create_connection(self.endpoint) + while True: + r = await ws.recv() + data = json.loads(r) + try: + if data["result"]["status"]: + yield data["result"]["payment_hash"] + except: + continue + except: + pass + logger.error("lost connection to cliche's websocket, retrying in 5 seconds") + await asyncio.sleep(5) diff --git a/poetry.lock b/poetry.lock index 48c508ce..07ed04ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -588,6 +588,19 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "websocket-client" +version = "1.3.3" +description = "WebSocket client for Python with low level API options" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "websockets" version = "10.0" @@ -605,7 +618,7 @@ optional = false python-versions = ">=3.5" [package.extras] -dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [[package]] name = "zipp" @@ -622,7 +635,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "921a5f4fe1a4d1a4c3b490f8631ed4bdd0d8af1f1992f1a4f74eaed986c4eb0b" +content-hash = "f1d88c59b67ee4198292f28ddd2caa33249b4dee85eda0c75c1c2100a583c55e" [metadata.files] aiofiles = [ @@ -1131,6 +1144,10 @@ watchgod = [ {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"}, {file = "watchgod-0.7.tar.gz", hash = "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29"}, ] +websocket-client = [ + {file = "websocket-client-1.3.3.tar.gz", hash = "sha256:d58c5f284d6a9bf8379dab423259fe8f85b70d5fa5d2916d5791a84594b122b1"}, + {file = "websocket_client-1.3.3-py3-none-any.whl", hash = "sha256:5d55652dc1d0b3c734f044337d929aaf83f4f9138816ec680c1aefefb4dc4877"}, +] websockets = [ {file = "websockets-10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da"}, {file = "websockets-10.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939"}, @@ -1158,7 +1175,10 @@ websockets = [ {file = "websockets-10.0-cp39-cp39-win_amd64.whl", hash = "sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567"}, {file = "websockets-10.0.tar.gz", hash = "sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002"}, ] -win32-setctime = [] +win32-setctime = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] zipp = [ {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, diff --git a/pyproject.toml b/pyproject.toml index 5c4bc7a0..aeb3a71d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ websockets = "10.0" zipp = "3.5.0" loguru = "0.5.3" cffi = "1.15.0" +websocket-client = "^1.3.3" [tool.poetry.dev-dependencies] diff --git a/requirements.txt b/requirements.txt index 23d428e5..697ea1d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,3 +50,4 @@ uvicorn==0.18.2 uvloop==0.16.0 watchfiles==0.16.0 websockets==10.3 +websocket-client==1.3.3