From 021cf64c1e82208b32200d491601c6c9cf2a5a56 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 16:13:48 +0100 Subject: [PATCH 001/209] Added eclair backend --- .env.example | 6 +- lnbits/wallets/__init__.py | 1 + lnbits/wallets/eclair.py | 166 +++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 lnbits/wallets/eclair.py diff --git a/.env.example b/.env.example index cc70644c..38add8fd 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet +# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, 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. @@ -56,3 +56,7 @@ LNTXBOT_KEY=LNTXBOT_ADMIN_KEY # OpenNodeWallet OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_KEY=OPENNODE_ADMIN_KEY + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8080 +ECLAIR_PASS=eclair_password diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 10a17c6f..e1b37f6f 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -9,3 +9,4 @@ from .lnpay import LNPayWallet from .lnbits import LNbitsWallet from .lndrest import LndRestWallet from .spark import SparkWallet +from .eclair import EclairWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py new file mode 100644 index 00000000..2f29501c --- /dev/null +++ b/lnbits/wallets/eclair.py @@ -0,0 +1,166 @@ +import trio +import json +import httpx +import random +import base64 +import urllib.parse +from os import getenv +from typing import Optional, AsyncGenerator +from trio_websocket import open_websocket_url + +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) + +class EclairError(Exception): + pass + + +class UnknownError(Exception): + pass + + +class EclairWallet(Wallet): + def __init__(self): + url = getenv("ECLAIR_URL") + self.url = url[:-1] if url.endswith("/") else url + + passw = getenv("ECLAIR_PASS") + encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) + auth = str(encodedAuth, "utf-8") + self.auth = {"Authorization": f"Basic {auth}"} + + def __getattr__(self, key): + async def call(*args, **kwargs): + if args and kwargs: + raise TypeError( + f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" + ) + elif args: + params = args + elif kwargs: + params = kwargs + else: + params = {} + + try: + async with httpx.AsyncClient() as client: + r = await client.post( + self.url + "/" + key, + headers=self.auth, + data=params, + timeout=40, + ) + except (OSError, httpx.ConnectError, httpx.RequestError) as exc: + raise UnknownError("error connecting to eclair: " + str(exc)) + + try: + data = r.json() + if "error" in data: + print('ERROR', data["error"]) + raise EclairError(data["error"]) + except: + raise UnknownError(r.text) + + #if r.error: + # print('ERROR', r) + # if r.status_code == 401: + # raise EclairError("Access key invalid!") + + #raise EclairError(data.error) + return data + + return call + + async def status(self) -> StatusResponse: + try: + funds = await self.usablebalances() + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse("Couldn't connect to Eclair server", 0) + except (EclairError, UnknownError) as e: + return StatusResponse(str(e), 0) + if not funds: + return StatusResponse("Funding wallet has no funds", 0) + + return StatusResponse( + None, + funds[0]["canSend"] * 1000, + ) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> InvoiceResponse: + if description_hash: + raise Unsupported("description_hash") + + try: + r = await self.createinvoice( + amountMsat=amount * 1000, + description=memo or "", + exposeprivatechannels=True, + ) + ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" + except (EclairError, UnknownError) as e: + ok, payment_request, error_message = False, None, str(e) + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + try: + r = await self.payinvoice(invoice=bolt11, blocking=True) + except (EclairError, UnknownError) as exc: + return PaymentResponse(False, None, 0, None, str(exc)) + + preimage = r["paymentPreimage"] + return PaymentResponse(True, r["paymentHash"], 0, preimage, None) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.getreceivedinfo(paymentHash=checking_id) + + except (EclairError, UnknownError): + return PaymentStatus(None) + + if r["status"]["type"] != "received": + return PaymentStatus(False) + return PaymentStatus(True) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + # check if it's 32 bytes hex + if len(checking_id) != 64: + return PaymentStatus(None) + try: + int(checking_id, 16) + except ValueError: + return PaymentStatus(None) + + try: + r = await self.getsentinfo(paymentHash=checking_id) + except (EclairError, UnknownError): + return PaymentStatus(None) + + raise KeyError("supplied an invalid checking_id") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = urllib.parse.urlsplit(self.url) + ws_url = f"ws://{url.netloc}/ws" + + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if message["type"] == "payment-received": + print('Received message: %s' % message) + yield message["paymentHash"] + + except OSError as ose: + pass + + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 4585d97324aa2ce7ea244c2b5ab35293a97634f7 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 18:50:01 +0100 Subject: [PATCH 002/209] small fix on paid_invoices_stream --- lnbits/wallets/eclair.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 2f29501c..9c8fa540 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -152,15 +152,17 @@ class EclairWallet(Wallet): url = urllib.parse.urlsplit(self.url) ws_url = f"ws://{url.netloc}/ws" - try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() - if message["type"] == "payment-received": - print('Received message: %s' % message) - yield message["paymentHash"] + while True: + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if "payment-received" in message["type"]: + print('Received message: %s' % message) + yield message["paymentHash"] - except OSError as ose: - pass + except OSError as ose: + print('OSE', ose) + pass - print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 756b121105848b4042f3e54bc5857afd637a46d4 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 25 Jun 2021 10:15:50 +0100 Subject: [PATCH 003/209] print errors for tracking/debugging --- lnbits/wallets/eclair.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9c8fa540..59d2ffdf 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -61,7 +61,7 @@ class EclairWallet(Wallet): try: data = r.json() if "error" in data: - print('ERROR', data["error"]) + print(f"ERROR-{key}", data["error"]) raise EclairError(data["error"]) except: raise UnknownError(r.text) @@ -156,8 +156,9 @@ class EclairWallet(Wallet): try: async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: message = await ws.get_message() + print('Received message: %s' % message) + if "payment-received" in message["type"]: - print('Received message: %s' % message) yield message["paymentHash"] except OSError as ose: From 165d663491e26671e9915cd91f184c1762e9a382 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 00:36:11 +0100 Subject: [PATCH 004/209] Added freedom forum theme --- lnbits/static/scss/base.scss | 221 ++++++++++++++--------------------- lnbits/templates/base.html | 70 +++++------ 2 files changed, 121 insertions(+), 170 deletions(-) diff --git a/lnbits/static/scss/base.scss b/lnbits/static/scss/base.scss index afafd50d..3668e773 100644 --- a/lnbits/static/scss/base.scss +++ b/lnbits/static/scss/base.scss @@ -1,186 +1,143 @@ -$themes: ( - 'classic': ( - primary: #673ab7, - secondary: #9c27b0, - dark: #1f2234, - info: #333646, - marginal-bg: #1f2234, - marginal-text: #fff - ), - 'bitcoin': ( - primary: #ff9853, - secondary: #ff7353, - dark: #2d293b, - info: #333646, - marginal-bg: #2d293b, - marginal-text: #fff - ), - 'mint': ( - primary: #3ab77d, - secondary: #27b065, - dark: #1f342b, - info: #334642, - marginal-bg: #1f342b, - marginal-text: #fff - ), - 'autumn': ( - primary: #b7763a, - secondary: #b07927, - dark: #34291f, - info: #463f33, - marginal-bg: #342a1f, - marginal-text: rgb(255, 255, 255) - ), - 'flamingo': ( - primary: #d11d53, - secondary: #db3e6d, - dark: #803a45, - info: #ec7599, - marginal-bg: #803a45, - marginal-text: rgb(255, 255, 255) - ), - 'monochrome': ( - primary: #494949, - secondary: #6b6b6b, - dark: #000, - info: rgb(39, 39, 39), - marginal-bg: #000, - marginal-text: rgb(255, 255, 255) - ) -); - -@each $theme, $colors in $themes { - @each $name, $color in $colors { - @if $name == 'dark' { - [data-theme='#{$theme}'] .q-drawer--dark, - body[data-theme='#{$theme}'].body--dark, - [data-theme='#{$theme}'] .q-menu--dark { - background: $color !important; - } - - /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE +$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255))); +@each $theme, +$colors in $themes { + @each $name, + $color in $colors { + @if $name=='dark' { + [data-theme='#{$theme}'] .q-drawer--dark, + body[data-theme='#{$theme}'].body--dark, + [data-theme='#{$theme}'] .q-menu--dark { + background: $color !important; + } + /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE // set a darker body bg for all themes, when in "dark mode" body[data-theme='#{$theme}'].body--dark { background: scale-color($color, $lightness: -60%); } */ + } + @if $name=='info' { + [data-theme='#{$theme}'] .q-card--dark, + [data-theme='#{$theme}'] .q-stepper--dark { + background: $color !important; + } + } } - @if $name == 'info' { - [data-theme='#{$theme}'] .q-card--dark, - [data-theme='#{$theme}'] .q-stepper--dark { - background: $color !important; - } + [data-theme='#{$theme}'] { + @each $name, + $color in $colors { + .bg-#{$name} { + background: $color !important; + } + .text-#{$name} { + color: $color !important; + } + } } - } - [data-theme='#{$theme}'] { - @each $name, $color in $colors { - .bg-#{$name} { - background: $color !important; - } - .text-#{$name} { - color: $color !important; - } - } - } } + +[data-theme='freedom'] .q-drawer--dark { + background: #0a0a0a !important; +} + +[data-theme='freedom'] .q-header { + background: #0a0a0a !important; +} + [data-theme='salvador'] .q-drawer--dark { - background: #242424 !important; + background: #242424 !important; } [data-theme='salvador'] .q-header { - background: #0f47af !important; + background: #0f47af !important; } [data-theme='flamingo'] .q-drawer--dark { - background: #e75480 !important; + background: #e75480 !important; } [data-theme='flamingo'] .q-header { - background: #e75480 !important; + background: #e75480 !important; } [v-cloak] { - display: none; + display: none; } body.body--dark .q-table--dark { - background: transparent; + background: transparent; } body.body--dark .q-field--error { - .text-negative, - .q-field__messages { - color: yellow !important; - } + .text-negative, + .q-field__messages { + color: yellow !important; + } } .lnbits-drawer__q-list .q-item { - padding-top: 5px !important; - padding-bottom: 5px !important; - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; - - &.q-item--active { - color: inherit; - font-weight: bold; - } + padding-top: 5px !important; + padding-bottom: 5px !important; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + &.q-item--active { + color: inherit; + font-weight: bold; + } } .lnbits__dialog-card { - width: 500px; + width: 500px; } .q-table--dense { - th:first-child, - td:first-child, - .q-table__bottom { - padding-left: 6px !important; - } - - th:last-child, - td:last-child, - .q-table__bottom { - padding-right: 6px !important; - } + th:first-child, + td:first-child, + .q-table__bottom { + padding-left: 6px !important; + } + th:last-child, + td:last-child, + .q-table__bottom { + padding-right: 6px !important; + } } a.inherit { - color: inherit; - text-decoration: none; + color: inherit; + text-decoration: none; } // QR video - video { - border-radius: 3px; + border-radius: 3px; } // Material icons font - @font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - src: url(../fonts/material-icons-v50.woff2) format('woff2'); + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url(../fonts/material-icons-v50.woff2) format('woff2'); } .material-icons { - font-family: 'Material Icons'; - font-weight: normal; - font-style: normal; - font-size: 24px; - line-height: 1; - letter-spacing: normal; - text-transform: none; - display: inline-block; - white-space: nowrap; - word-wrap: normal; - direction: ltr; - -moz-font-feature-settings: 'liga'; - -moz-osx-font-smoothing: grayscale; + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; + -moz-osx-font-smoothing: grayscale; } // text-wrap .text-wrap { - word-break: break-word; -} + word-break: break-word; +} \ No newline at end of file diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 965d5336..610b2a75 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -1,49 +1,33 @@ - - {% for url in VENDORED_CSS %} - - {% endfor %} - - - {% block styles %}{% endblock %} + + {% for url in VENDORED_CSS %} + {% endfor %} + + {% block styles %}{% endblock %} {% block title %}{{ SITE_TITLE }}{% endblock %} - + - - {% block head_scripts %}{% endblock %} - + {% block head_scripts %}{% endblock %} + - + - - - {% block drawer_toggle %} - - {% endblock %} - - - {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ - SITE_TITLE }} {% else %} LNbits {% endif %} {% - endblock %} - - {% block beta %} - - + + {% block drawer_toggle %} + + {% endblock %} + + + {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} LNbits {% endif %} {% endblock %} + + {% block beta %} + + USE WITH CAUTION - {{SITE_TITLE}} wallet is still in BETA @@ -118,6 +102,16 @@ size="md" >elSalvador + Freedom + {% block scripts %}{% endblock %} - + \ No newline at end of file From 1874d7fd5488e9dbd12cc5fcf7694ff2185b7de4 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 10:42:55 +0100 Subject: [PATCH 005/209] TPoS now theme dependent --- .../extensions/tpos/templates/tpos/tpos.html | 455 ++++++++---------- 1 file changed, 192 insertions(+), 263 deletions(-) diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index d05fab4e..d1444989 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,278 +1,207 @@ -{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock -%} {% block footer %}{% endblock %} {% block page_container %} +{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock %} {% block footer %}{% endblock %} {% block page_container %} - - -
-
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
-
- -
-
-
- 1 - 2 - 3 - C - 4 - 5 - 6 - 7 - 8 - 9 - OK - DEL - 0 - # -
-
-
-
- - - - - -
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
- Close -
-
-
- - - - - -
-

- {{ tpos.name }}
{{ request.url }} -

-
-
- Copy URL - Close -
-
-
+ + +
+
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+
+ +
+
+
+ 1 + 2 + 3 + C + 4 + 5 + 6 + 7 + 8 + 9 + OK + DEL + 0 + # +
+
+
+
+ + + + + +
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+ Close +
+
+
+ + + + + +
+

+ {{ tpos.name }}
{{ request.url }} +

+
+
+ Copy URL + Close +
+
+
- - -
+ + + +
{% endblock %} {% block styles %} {% endblock %} {% block scripts %} -{% endblock %} +{% endblock %} \ No newline at end of file From 8042dc51b30d76580b8c0b7a944491ba81cc012c Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:16:00 +0100 Subject: [PATCH 006/209] prettier tpos --- .../extensions/tpos/templates/tpos/tpos.html | 523 +++++++++++------- 1 file changed, 332 insertions(+), 191 deletions(-) diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index d1444989..49d88140 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,207 +1,348 @@ -{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock %} {% block footer %}{% endblock %} {% block page_container %} +{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock +%} {% block footer %}{% endblock %} {% block page_container %} - - -
-
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
-
- -
-
-
- 1 - 2 - 3 - C - 4 - 5 - 6 - 7 - 8 - 9 - OK - DEL - 0 - # -
-
-
-
- - - - - -
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
- Close -
-
-
- - - - - -
-

- {{ tpos.name }}
{{ request.url }} -

-
-
- Copy URL - Close -
-
-
+ + +
+
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+
+ +
+
+
+ 1 + 2 + 3 + C + 4 + 5 + 6 + 7 + 8 + 9 + OK + DEL + 0 + # +
+
+
+
+ + + + + +
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+ Close +
+
+
+ + + + + +
+

+ {{ tpos.name }}
{{ request.url }} +

+
+
+ Copy URL + Close +
+
+
- - - -
+ + + +
{% endblock %} {% block styles %} {% endblock %} {% block scripts %} -{% endblock %} \ No newline at end of file +{% endblock %} From a36ad05fe0b376868f0b5a429abbd4110aff0abe Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:20:39 +0100 Subject: [PATCH 007/209] Added .env for theme --- .env.example | 4 +-- lnbits/templates/base.html | 57 ++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index ab7f8096..4849fd06 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,8 @@ LNBITS_SERVICE_FEE="0.0" LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" -# Choose from mint, flamingo, salvador, autumn, monochrome, classic -LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador" +# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic +LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, # LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 610b2a75..8ceb44a9 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -1,33 +1,48 @@ - - + {% for url in VENDORED_CSS %} - {% endfor %} + + {% endfor %} - {% block styles %}{% endblock %} + + {% block styles %}{% endblock %} {% block title %}{{ SITE_TITLE }}{% endblock %} - + - {% block head_scripts %}{% endblock %} - + + {% block head_scripts %}{% endblock %} + - + - - - {% block drawer_toggle %} - - {% endblock %} - - - {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} LNbits {% endif %} {% endblock %} - - {% block beta %} - - + + {% block drawer_toggle %} + + {% endblock %} + + + {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ + SITE_TITLE }} {% else %} LNbits {% endif %} {% + endblock %} + + {% block beta %} + + USE WITH CAUTION - {{SITE_TITLE}} wallet is still in BETA @@ -207,4 +222,4 @@ {% block scripts %}{% endblock %} - \ No newline at end of file + From 2fd12e1d19942d3d11f4f585c5d18ddc9d2bc5f3 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:23:11 +0100 Subject: [PATCH 008/209] Changed theme picker color --- lnbits/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 8ceb44a9..d61a0ccf 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -123,7 +123,7 @@ flat @click="changeColor('freedom')" icon="format_color_fill" - color="pink-1" + color="pink-13" size="md" >Freedom
From d77e304e127ffb5e3eb6933e3166bb8a43a54f82 Mon Sep 17 00:00:00 2001 From: Ashkan Jalali <55811147+ashkanjalaliQ@users.noreply.github.com> Date: Thu, 28 Apr 2022 17:07:58 +0430 Subject: [PATCH 009/209] Update _api_docs.html add "webhook" and "unit" arguments to api document --- lnbits/core/templates/core/_api_docs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/core/templates/core/_api_docs.html b/lnbits/core/templates/core/_api_docs.html index 0e74f38e..5df37676 100644 --- a/lnbits/core/templates/core/_api_docs.html +++ b/lnbits/core/templates/core/_api_docs.html @@ -48,7 +48,7 @@ {"X-Api-Key": "{{ wallet.inkey }}"}
Body (application/json)
{"out": false, "amount": <int>, "memo": <string>}{"out": false, "amount": <int>, "memo": <string>, "unit": <string>, "webhook": <url:string>}
Returns 201 CREATED (application/json) From 2cd6626ca1c59d99a445426eba6922c0e00f7e4c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Apr 2022 11:39:27 +0100 Subject: [PATCH 010/209] get status --- lnbits/wallets/__init__.py | 14 +++++++------- lnbits/wallets/eclair.py | 25 ++++++++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index f3f25103..8a2ca1a5 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -1,12 +1,12 @@ # flake8: noqa -from .void import VoidWallet from .clightning import CLightningWallet -from .lntxbot import LntxbotWallet -from .opennode import OpenNodeWallet -from .lnpay import LNPayWallet -from .lnbits import LNbitsWallet -from .lndrest import LndRestWallet -from .spark import SparkWallet from .eclair import EclairWallet from .fake import FakeWallet +from .lnbits import LNbitsWallet +from .lndrest import LndRestWallet +from .lnpay import LNPayWallet +from .lntxbot import LntxbotWallet +from .opennode import OpenNodeWallet +from .spark import SparkWallet +from .void import VoidWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 59d2ffdf..9b5aca7d 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,21 +1,24 @@ -import trio -import json -import httpx -import random +import asyncio import base64 +import json +import random import urllib.parse from os import getenv -from typing import Optional, AsyncGenerator -from trio_websocket import open_websocket_url +from typing import AsyncGenerator, Optional + +import httpx +from websockets import connect from .base import ( - StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, + StatusResponse, + Unsupported, Wallet, ) + class EclairError(Exception): pass @@ -154,11 +157,11 @@ class EclairWallet(Wallet): while True: try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() + async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.recv() print('Received message: %s' % message) - if "payment-received" in message["type"]: + if "type" in message and "payment-received" in message.type: yield message["paymentHash"] except OSError as ose: @@ -166,4 +169,4 @@ class EclairWallet(Wallet): pass print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + await asyncio.sleep(5) From 96df280d49e13d090b3f336e504c698dc2a3c79e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:44:34 +0100 Subject: [PATCH 011/209] adding fees --- lnbits/wallets/eclair.py | 232 ++++++++++++++++++++++----------------- 1 file changed, 130 insertions(+), 102 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9b5aca7d..e2ba7d36 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,20 +1,23 @@ import asyncio import base64 import json -import random import urllib.parse from os import getenv -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator, Dict, Optional import httpx from websockets import connect +from websockets.exceptions import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, +) from .base import ( InvoiceResponse, PaymentResponse, PaymentStatus, StatusResponse, - Unsupported, Wallet, ) @@ -26,73 +29,37 @@ class EclairError(Exception): class UnknownError(Exception): pass - class EclairWallet(Wallet): def __init__(self): url = getenv("ECLAIR_URL") self.url = url[:-1] if url.endswith("/") else url + self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws" + passw = getenv("ECLAIR_PASS") encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) auth = str(encodedAuth, "utf-8") self.auth = {"Authorization": f"Basic {auth}"} - def __getattr__(self, key): - async def call(*args, **kwargs): - if args and kwargs: - raise TypeError( - f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" - ) - elif args: - params = args - elif kwargs: - params = kwargs - else: - params = {} - - try: - async with httpx.AsyncClient() as client: - r = await client.post( - self.url + "/" + key, - headers=self.auth, - data=params, - timeout=40, - ) - except (OSError, httpx.ConnectError, httpx.RequestError) as exc: - raise UnknownError("error connecting to eclair: " + str(exc)) - - try: - data = r.json() - if "error" in data: - print(f"ERROR-{key}", data["error"]) - raise EclairError(data["error"]) - except: - raise UnknownError(r.text) - - #if r.error: - # print('ERROR', r) - # if r.status_code == 401: - # raise EclairError("Access key invalid!") - - #raise EclairError(data.error) - return data - - return call async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/usablebalances", + headers=self.auth, + timeout=40 + ) try: - funds = await self.usablebalances() - except (httpx.ConnectError, httpx.RequestError): - return StatusResponse("Couldn't connect to Eclair server", 0) - except (EclairError, UnknownError) as e: - return StatusResponse(str(e), 0) - if not funds: - return StatusResponse("Funding wallet has no funds", 0) + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0 + ) + + if r.is_error: + return StatusResponse(data["error"], 0) - return StatusResponse( - None, - funds[0]["canSend"] * 1000, - ) + return StatusResponse(None, data[0]["canSend"] * 1000) async def create_invoice( self, @@ -100,73 +67,134 @@ class EclairWallet(Wallet): memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> InvoiceResponse: + + data: Dict = {"amountMsat": amount * 1000} if description_hash: - raise Unsupported("description_hash") + data["description_hash"] = description_hash.hex() + else: + data["description"] = memo or "" - try: - r = await self.createinvoice( - amountMsat=amount * 1000, - description=memo or "", - exposeprivatechannels=True, - ) - ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" - except (EclairError, UnknownError) as e: - ok, payment_request, error_message = False, None, str(e) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/createinvoice", + headers=self.auth, + data=data, + timeout=40 + ) - return InvoiceResponse(ok, checking_id, payment_request, error_message) + if r.is_error: + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass - async def pay_invoice(self, bolt11: str) -> PaymentResponse: - try: - r = await self.payinvoice(invoice=bolt11, blocking=True) - except (EclairError, UnknownError) as exc: - return PaymentResponse(False, None, 0, None, str(exc)) + return InvoiceResponse(False, None, None, error_message) + + data = r.json() + return InvoiceResponse(True, data["paymentHash"], data["serialized"], None) + + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/payinvoice", + headers=self.auth, + data={"invoice": bolt11, "blocking": True}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + + + checking_id = data["paymentHash"] + preimage = data["paymentPreimage"] + + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + fees = [i["status"] for i in data] + fee_msat = sum([i["feesPaid"] for i in fees]) + + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + - preimage = r["paymentPreimage"] - return PaymentResponse(True, r["paymentHash"], 0, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - try: - r = await self.getreceivedinfo(paymentHash=checking_id) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getreceivedinfo", + headers=self.auth, + data={"paymentHash": checking_id} + ) + data = r.json() - except (EclairError, UnknownError): + if r.is_error or "error" in data: return PaymentStatus(None) - if r["status"]["type"] != "received": + if data["status"]["type"] != "received": return PaymentStatus(False) - return PaymentStatus(True) + + return PaymentStatus(True) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - # check if it's 32 bytes hex - if len(checking_id) != 64: - return PaymentStatus(None) - try: - int(checking_id, 16) - except ValueError: - return PaymentStatus(None) + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id} - try: - r = await self.getsentinfo(paymentHash=checking_id) - except (EclairError, UnknownError): - return PaymentStatus(None) + ) - raise KeyError("supplied an invalid checking_id") + data = r.json()[0] + + if r.is_error: + return PaymentStatus(None) + + if data["status"]["type"] != "sent": + return PaymentStatus(False) + + return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = urllib.parse.urlsplit(self.url) - ws_url = f"ws://{url.netloc}/ws" - - while True: - try: - async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + + try: + async with connect(self.ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + while True: message = await ws.recv() - print('Received message: %s' % message) + message = json.loads(message) - if "type" in message and "payment-received" in message.type: + if message and message["type"] == "payment-received": yield message["paymentHash"] - except OSError as ose: - print('OSE', ose) - pass + except (OSError, ConnectionClosedOK, ConnectionClosedError, ConnectionClosed) as ose: + print('OSE', ose) + pass print("lost connection to eclair's websocket, retrying in 5 seconds") await asyncio.sleep(5) From 1695d0ce0ea5c3791eab90d717b000cd1bae9358 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:47:25 +0100 Subject: [PATCH 012/209] send 0 fees when not available --- lnbits/wallets/eclair.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index e2ba7d36..04fd498f 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -135,7 +135,7 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(True, checking_id, 0, preimage, error_message) ## ?? is this ok ?? data = r.json() fees = [i["status"] for i in data] From e0504a4e0c16d64e91984bdde83a71ee686e8564 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:55:01 +0100 Subject: [PATCH 013/209] add eclair variables to .env example --- .env.example | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4849fd06..14a87d02 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet +# 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. @@ -77,4 +77,8 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY # FakeWallet FAKE_WALLET_SECRET="ToTheMoon1" -LNBITS_DENOMINATION=sats \ No newline at end of file +LNBITS_DENOMINATION=sats + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8283 +ECLAIR_PASS=eclairpw \ No newline at end of file From 0c089109b925eefcc3f9e4c1985b34c5102d5ac8 Mon Sep 17 00:00:00 2001 From: Bitcoin Gamer 21 <98273902+bitcoingamer21@users.noreply.github.com> Date: Fri, 6 May 2022 20:26:42 +0200 Subject: [PATCH 014/209] Edited discord bot extension to use FastAPI (#570) Squashed 9 commits: * Added Discordbot Extension * Removed backup files * Fixing renaming wallet * added further description * further description index.html * import staticfiles * remove duplicate file * deleted changes in core --- lnbits/extensions/discordbot/Pipfile | 11 + lnbits/extensions/discordbot/README.md | 34 ++ lnbits/extensions/discordbot/__init__.py | 25 + lnbits/extensions/discordbot/config.json | 6 + lnbits/extensions/discordbot/crud.py | 123 +++++ lnbits/extensions/discordbot/migrations.py | 30 ++ lnbits/extensions/discordbot/models.py | 36 ++ lnbits/extensions/discordbot/static/stack.png | Bin 0 -> 73993 bytes .../templates/discordbot/_api_docs.html | 260 ++++++++++ .../templates/discordbot/index.html | 464 ++++++++++++++++++ lnbits/extensions/discordbot/views.py | 15 + lnbits/extensions/discordbot/views_api.py | 127 +++++ 12 files changed, 1131 insertions(+) create mode 100644 lnbits/extensions/discordbot/Pipfile create mode 100644 lnbits/extensions/discordbot/README.md create mode 100644 lnbits/extensions/discordbot/__init__.py create mode 100644 lnbits/extensions/discordbot/config.json create mode 100644 lnbits/extensions/discordbot/crud.py create mode 100644 lnbits/extensions/discordbot/migrations.py create mode 100644 lnbits/extensions/discordbot/models.py create mode 100644 lnbits/extensions/discordbot/static/stack.png create mode 100644 lnbits/extensions/discordbot/templates/discordbot/_api_docs.html create mode 100644 lnbits/extensions/discordbot/templates/discordbot/index.html create mode 100644 lnbits/extensions/discordbot/views.py create mode 100644 lnbits/extensions/discordbot/views_api.py diff --git a/lnbits/extensions/discordbot/Pipfile b/lnbits/extensions/discordbot/Pipfile new file mode 100644 index 00000000..d5820662 --- /dev/null +++ b/lnbits/extensions/discordbot/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/lnbits/extensions/discordbot/README.md b/lnbits/extensions/discordbot/README.md new file mode 100644 index 00000000..a1408317 --- /dev/null +++ b/lnbits/extensions/discordbot/README.md @@ -0,0 +1,34 @@ +# Discord Bot + +## Provide LNbits wallets for all your Discord users + +_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_ + +The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node. + +## Setup +This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance. + +To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation) + +## Usage +This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands): + +`/create` Will create a wallet for the Discord user + - (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet) + +![create](https://imgur.com/CWdDusE.png) + +`/balance` Will show the balance of the users wallet. + +![balance](https://imgur.com/tKeReCp.png) + +`/tip @user [amount]` Will sent money from one user to another + - If the recieving user does not have a wallet, one will be created for them + - The receiving user will receive a direct message from the bot with a link to their wallet + +![tip](https://imgur.com/K3tnChK.png) + +`/payme [amount] [description]` Will open an invoice that can be paid by any user + +![payme](https://imgur.com/dFvAqL3.png) diff --git a/lnbits/extensions/discordbot/__init__.py b/lnbits/extensions/discordbot/__init__.py new file mode 100644 index 00000000..ff60dd62 --- /dev/null +++ b/lnbits/extensions/discordbot/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_discordbot") + +discordbot_static_files = [ + { + "path": "/discordbot/static", + "app": StaticFiles(directory="lnbits/extensions/discordbot/static"), + "name": "discordbot_static", + } +] + +discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"]) + + +def discordbot_renderer(): + return template_renderer(["lnbits/extensions/discordbot/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/discordbot/config.json b/lnbits/extensions/discordbot/config.json new file mode 100644 index 00000000..eb674122 --- /dev/null +++ b/lnbits/extensions/discordbot/config.json @@ -0,0 +1,6 @@ +{ + "name": "Discord Bot", + "short_description": "Generate users and wallets", + "icon": "person_add", + "contributors": ["bitcoingamer21"] +} diff --git a/lnbits/extensions/discordbot/crud.py b/lnbits/extensions/discordbot/crud.py new file mode 100644 index 00000000..5661fcb4 --- /dev/null +++ b/lnbits/extensions/discordbot/crud.py @@ -0,0 +1,123 @@ +from typing import List, Optional + +from lnbits.core.crud import ( + create_account, + create_wallet, + delete_wallet, + get_payments, + get_user, +) +from lnbits.core.models import Payment + +from . import db +from .models import CreateUserData, Users, Wallets + +### Users + + +async def create_discordbot_user(data: CreateUserData) -> Users: + account = await create_account() + user = await get_user(account.id) + assert user, "Newly created user couldn't be retrieved" + + wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name) + + await db.execute( + """ + INSERT INTO discordbot.users (id, name, admin, discord_id) + VALUES (?, ?, ?, ?) + """, + (user.id, data.user_name, data.admin_id, data.discord_id), + ) + + await db.execute( + """ + INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + wallet.id, + data.admin_id, + data.wallet_name, + user.id, + wallet.adminkey, + wallet.inkey, + ), + ) + + user_created = await get_discordbot_user(user.id) + assert user_created, "Newly created user couldn't be retrieved" + return user_created + + +async def get_discordbot_user(user_id: str) -> Optional[Users]: + row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,)) + return Users(**row) if row else None + + +async def get_discordbot_users(user_id: str) -> List[Users]: + rows = await db.fetchall( + "SELECT * FROM discordbot.users WHERE admin = ?", (user_id,) + ) + + return [Users(**row) for row in rows] + + +async def delete_discordbot_user(user_id: str) -> None: + wallets = await get_discordbot_wallets(user_id) + for wallet in wallets: + await delete_wallet(user_id=user_id, wallet_id=wallet.id) + + await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,)) + await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,)) + + +### Wallets + + +async def create_discordbot_wallet( + user_id: str, wallet_name: str, admin_id: str +) -> Wallets: + wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name) + await db.execute( + """ + INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey), + ) + wallet_created = await get_discordbot_wallet(wallet.id) + assert wallet_created, "Newly created wallet couldn't be retrieved" + return wallet_created + + +async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets(**row) if row else None + + +async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + "SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + """SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]: + return await get_payments( + wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True + ) + + +async def delete_discordbot_wallet(wallet_id: str, user_id: str) -> None: + await delete_wallet(user_id=user_id, wallet_id=wallet_id) + await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,)) diff --git a/lnbits/extensions/discordbot/migrations.py b/lnbits/extensions/discordbot/migrations.py new file mode 100644 index 00000000..ababfd7a --- /dev/null +++ b/lnbits/extensions/discordbot/migrations.py @@ -0,0 +1,30 @@ +async def m001_initial(db): + """ + Initial users table. + """ + await db.execute( + """ + CREATE TABLE discordbot.users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + admin TEXT NOT NULL, + discord_id TEXT + ); + """ + ) + + """ + Initial wallets table. + """ + await db.execute( + """ + CREATE TABLE discordbot.wallets ( + id TEXT PRIMARY KEY, + admin TEXT NOT NULL, + name TEXT NOT NULL, + "user" TEXT NOT NULL, + adminkey TEXT NOT NULL, + inkey TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/discordbot/models.py b/lnbits/extensions/discordbot/models.py new file mode 100644 index 00000000..4be367f8 --- /dev/null +++ b/lnbits/extensions/discordbot/models.py @@ -0,0 +1,36 @@ +from sqlite3 import Row + +from fastapi.param_functions import Query +from pydantic import BaseModel +from typing import Optional + + +class CreateUserData(BaseModel): + user_name: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + discord_id: str = Query("") + +class CreateUserWallet(BaseModel): + user_id: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + + +class Users(BaseModel): + id: str + name: str + admin: str + discord_id: str + +class Wallets(BaseModel): + id: str + admin: str + name: str + user: str + adminkey: str + inkey: str + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) diff --git a/lnbits/extensions/discordbot/static/stack.png b/lnbits/extensions/discordbot/static/stack.png new file mode 100644 index 0000000000000000000000000000000000000000..3b987db118ad79196f3592038cc77b1c4fa4ad92 GIT binary patch literal 73993 zcmeAS@N?(olHy`uVBq!ia0y~yU^&OYz~sTf#=yWZbHj>N3=BLOna<7up3cq+0Y&*~ znK`Kp3?7|R!`VY7dy3CpucD~9kX1=R^^2Ov`XHwaL6#+4W==vL8ipz?qSl*MJh_A*^y`@R46@8}ESw@Texuh}2} zzxw3|RwFrv6D%E6rWT&IFH+UIjkk=PvFme|+HylweQV5q%+ZQH>wdkzWG(A}5?4huY0v8vH0^?~nET-0HuFEOddn0XOhw$1%zdyHf3ZD9vu)Ok4 z`kk~9EtUpUQT_&eq8hUBN0!(HK*jw#hLO=bPKYG*}|-JLmJwKmR^0KM|sY9$Vmtu&bHBDsdrFe*tD?nAoF6T0EVCX&TBrs;xJ-! z<7P`sX-*e--sCZXx&AbJT4(zr)yCx$1pg{qiJQrtF=sbB`eXld_M7=U+(v;~ZgvtA zS6aUC(34Se=Z6~ zcgP+%m#Dsx`$pgzIfISD5zJ+h*2@k!?|%LAz&oiPG4l!jW>*CND$9k71_yf1Ebc8{ z{yF_=#J+^6eUFYM`%miv8BPrcojSRN?s`L6NyzQt^nA2S;M{9J0YbBn{thh5x^cB&>1=cY0E zG_Y=HeO<^p?*KnbV}OCwkp`9tOlk+UxF6b0Z(5n4HiO;EK{A3ZthqEn_y$*5lWl_i z3U>E{o&{ntOyLK83*_I;()@h^zoUSVlkTgmyM;Q7WH!+(Z47*w2@&1*k@O#Jcd4|6MI>!kMg z*&o~eaXpLF!Hx$GA5;rC9&^lWS=l1wWSMCFP~aiYp6ON`?>k+TnlqfgG<{FvGVHc! z-Z5iG}cB~f6oOe zfzJZ9G`3DLp29w*{MD;h&tBQRO1|p)s`#q*mG3L(hu5$9H*uBo*5#X43a==7CG?6f zOZcnaSJ_^-&C6IX24()-qPivPmgX(xsP$~IhdrAYAG|(eap20p<%PbFx2i>6_L{uZ z(sOgb=9te7&qUAa>28{~RV=!DwQ2O~pw}C&<*t>>-L!Vx8;z{NnS=+b^EKU|rI=%WRj-U7fo|cWrm+?z&rLRrRjQ zv+D8}t*?DAw;CF>nKmCjk*JgUCjCxQjZ~UVkExGA&czM460f(_UXWiWzeN9n{?+@d z>x2JKX1uI$Md5bBPRG3mot^JoxL2^|VaVa$wsfA|ZLj&hOP`aDv#R<3q1tW5#Jz!Y zF7hm1>o&c&s>iqIaIfmI>|TG#@|gxcO+K4^!e+{tzcg7on{7syPx#Dv2DK-zByXBw zd4}s*X4>|d6C+z@-pT2X?2eqjv$t|l<;0(HpWDSmxbwStx4__qr}x{Ke_Fk{y}0kQ+4J9eR(gwLyJDnb@9ny^`%{c|Z2pdmcMjco zw!?JC-oo+%@g4ak`yV@gmw3GKz~Y;ayO&R2er5Bu&6iJK(mtEL_j+!A?E8rKNAEqa z<*++wS6tKgtLDqsFK55segAv;{KfIx?(6LrU!3E{>6R;f^+?cF=+^)O0bGBr; z-aI*b%ewGCzgr%(Eam(zoGbja^Q`AAPvJ?cCfljBtE~6PlQu89v!-x*5Kpj+Chy86 zSC(9PyVCZt^YQg^_YDswJDqHa7S2`kn_ZDO>EV(&p2yX){hs;RO>pz@_Pagx+f3=h zYae}EQhe$9^779&_L%Mo|D*6vt8V#$_zg}UY!2HeyB_QI`G02W%(;gg5_fL!J|ebO zc<;%s6St;(RkT*Cb}y^`>CX6v0TcMd1X7H<3TMP#z(iqZL_(*=W6BM zOCB%FB$Z}sWS_X+D*tu*(`}#TKk4UY;?m--TCV16Yv5ZwtK!zvm)|an`_{)vg#9?p zQ^S_;_St9i%+G0d^N!BinrdcbU0e29rrYvpvDLG-^LH)#jr6PU<-4A9)$aN?Yrf6? zjW>2wzkv(Hk-q_XTX(kUH`9qOINgrhW3(pJq9I>h#*UGto64 z)2GkZu6q|hY2%XAmCrBrp6)n(9d-kUOK4ABA-}L><{#IqzKl%5J`5t=~f0S%Y zeMjAjEqp6y$6b%rUz4Bhk(ps@^Q)kEas1vjYrS>TP)AEwGtA5>m_kC~fyxik*?Kb=V zsQf$mw=C}W>gAi3&$sTHe{tSad-K}3zlXjay?AcJxz+l|_rIx7t-N|Y^i%sc`PcEc z_u2hOzc7FL?&GE9dnWFCS-<v#UYdPn&7=NGl_e(&R7x|eOQwS85+&3Db8 zmp|`YIp1@>_P!gnnSZ2yes!$RtKZ+E{gkhUD`CSzNlAu1cNrOUSsAQswg(>5KHtRL zrg45fGshqGH8%QuGt?QjZV0Y9ws|(g4fffk0$196KXT3&U6%N(|mmyv=|r|I2c$Mr5IQl7#J8C7#Qpr zrQvKhMhymLus9O~LwhCz3z*Ho;Ku*~6QFzu%{-}%ft`VYL5P8Yp<)3OOf}271X;?yGN{M_8syb?P^d_grMOF}i; z=!3kD6cmtf28#x{xY=>p=!0Vf6gzfY7wiAXFfcI4WV-l=FfcGMfn$|{QH6nlL4yHI zgVV6F(2oypMQSu1?w9PFK6WWm?kJVa5T2C zIMp#aaVa`va4vE1(BKrPa#9Gn?DKvZivi2*dv|j0toy!cxBc(ld2gp~-90HR>TJ)x ze~<5Fnmj%G{N(SvdH2q_1h4+@`hlOtfdL0n=yecj+-xGZgMm>12U2P{ASuU!OIBcm zLc@WXtRf*#E5mR~v8%_rtXkuVW7YhhrVNzhgjyUE#l*HjQ0R~2HCXP9g4vvDj zEay-tXiLZCdj=LA4uyg=Mpjo`mMb(Iker7zXokDzA5=eh^XarcdnzdQ1123UK zSHpp76CE)vxkeXr1qLQob%y`J1yAEWp4R%XuJhMQ)@{$dzsKTi6>7gd6@}JpJo~d(}^)Z}Pz;NUzE_#(B0<=mG|>S@eOR-kaZxr<}Y%iP#Ad@Ns-80u4U{aMbf*||W3 z#X+Hk<$=Z*BP*tKaIiAY&iF$)yIQFr?JVn31qLQX6$bZ((=Q5ITs}Q-1~118ABKPK>93UvmTfOJ zv=+F~4GL~s!O`Hx9^Bk63b(F%D<7Rb+w)jdYft%>%cX(g>?bq)`zJ7SDo%ECOETQ$ zCnnAv8RcaZXcrL^9@6!CRhHuFY|mwWQTgO&*~rJtI?)z#$i0+c%cavYFH&-Rxz9aL}jwPG6$j{7kS!;ZJhsDwT5}Veq>e}^I(s)|R{R~Cn ztzS##N*$5R%v}7~sTiC&B~UY`o)MP(@Jf)8<(v&uXC9XmL*F8W8Qf+ZOzEu7y^RhG zET2>u{!dnSI63>Uyl0-m3U(%i4bvF@T#ORQ+^}NS& zfBJc!nxyxKdnul~{8TR+pINb?!QO&dS4wzhmgYTeT{(5#UB_}=HdWvLs3LsTR7Kg< z)#=glY2du8EvAK%cT=$B^ec*tEazmHoJ%!14+MH-ie2|oEO;g*E7ionAu@yEkAGvM zc!kA}#Z1h{3ltcb)^Q$?k6i2wE**;nC0;C-I(mBAq%}3)_p5YUS$Q7&{oBJ$NNKZ< zP@-nn(*Kg;%S+X|j%3bdZa%6xSJwKy)2i?NQqD%9F-m_!7A&Y`DiHY=xiUbQTU2xn zi>xC9%Qb<9{}+E-HrQXfy$@7=xq!;AS>W<3Q;&&bj-VpTxguMQth?=t15U^7+;Y@O zRb257JEOpaR)#e?-SVQv?{y+Y)r z8y-ft^4}CrdwIn>xPDdar+FDGlQtfYjL+5eU6^dWk^R$r9-bMDPuhiof34g{V^=GHfHd~k-l>jg85!vX=82lZTzmOBg|^fx8QM zE>pU>{pB>3VAV$@=dPaoWoG3(bpgkLKo-|~^9wfyd~9F+X74fK+$BkeC$)PX6LM?Y z@}?{O!utI?dfu&(suemJ!cv`?=CHKH@K67xXQx-%{0a1PS;4@hpuh<#x}6mY($1>3 zEajN<6jYOaI;8HMY{3<&^RQPgVnxN_z^!ot7QtteE|l{!{*d20pZSOK-nXDS#Sc`c ztOeC6(?3j+l1#byPH$H6Pxj3oVRauZJ?(UjZ5;N@%v|M>!cvm6OSAOG*3auyCO;KT zVo<*D>}^d9uNI;Z}`zu};o>@qa8%&M4F>S@`utxsBb~%BLN* zD_6hk%I(#gxO0wvwB+r>|4(FyzAtIA|J0{i#l$G^VLHQ~iKg+Of~jS>NTYFlPVa%4 z`VqD7MfwhvA7Pbc;GWv_Xj+;9%ef_ntRjUD4F8TZ>aeapB(IravVw<6Q5952SAev? zh|gj5}@2JIX2 zOnry!v_-R$7A|UUPwvrte%{3`^1Z^w^k-cc)i2phGTeB5=Jz8P z7W1$?s889@RBv@a{^xNq+hzui3U7vgCuV`G{V)2i57sD{xjk5;FvC`wrSFjOl%SH9 z)-I7WjU7T9b3|n<*}gO|{OM;GP<_Yr&m-xvgQ);RqY2Xo_e_gxaS8?7#9Te<44KkC zrcI0t`~9fcH1adi~=9}K+)h04#()O1Ca*XTp#F2yyJ6kU0lH7^XXN@3XeR6 z^H-f8oGD)NVnYHbL$z5=XnfGmbg<9Sk%1*fpuv7qTibz|rsBNbN1K%P6|tUO`)#VY z$GX!dmkUk_`LEo6?1atjdalfw5=KWZh%H~rnrUXyrSP)a-b_sEp7GS(?hl`5O}F>T z3frcuy3+61%#GV4Un*Rx{<$OAbD7ekDQUY7@aJ+K_@9v-HA@1sZQ9+olw;1xln|D4 zDVvU2#Xiuuw(-J4bDfZq4T~mQDil0x5qovYmEoVeUUkJ<-aiwsyIhp#XlRHB<$;r+ zwvX%XOEWeukGxy#fALP&?OR)CFYXe4wSJ}A*4b+EUPh71e)mHP^{ig-xG!uLy?&OF zdupl9p^vjIc&lzS?atiQT(!F=v%6u|8=gsl8`~ovf3Wquy6zO`rqw)uZB!WkKUVbN z+}&h9>4HW8CyT>@Neq7`s!rnowG_8#yFT#oo2*ozHa+%%#*NZ=tk#AyT*!L$Tvw{oVm(Q4; zw7h4dV~}g%udTJSb3K4k;fk@R%QBa9IGu!N9h?f#@Nap-3T8=;e;NXBBt%#o4zPj>q6|>Aba9zTqw!AP&I2=JzBMkMa9ZwXR&%S@ zK9;Ll{Rd`7eQVnGlI21DrOFp&to2J~yBf+1Tma?&t+}A`bWu&_miRrD>nopm&hmcA zRX8gxWTyFtYib@-IxioS;-Bp$Ds*gK!o?#DvD44|o|STUwSIKj1TCTElU><`j@4Rz z+vj=o$UQS#W%mhjx|8|Ee@Xs|@?&eAC)}|3gG}ZE*`$R+8vmO3+h#>k5!Wjuf?i;+Yw_K^<#NR$eYq>mHT!q zR%ZMmFS~YEp5F)e*AWlQ1sE82f*J|8*+KmhuIWkz+m2Om%+dVA!M?&u#%Rk``P)&F45{jfP*l5gLt+Oq44R$o5S`+CK~?Uu<4Qm4nRto#0Bu7}&A zr2GvT>Flxmm2VU$xz3F}R$CYqdH%z~lDjoGF1qc!xGp|rRj9rCwKo%inXp}-fZi8>))Hl;ti+0pZAmgxC_sjsH}bPIhb zZoHlQ8=pMiWs?#m%}as0-z>Ppw0q=47=O%Pa6&vS=Eveg1;;cL7?^$vH`s4V>j0H( ztloka(a9z(=R|5sRD&e@oRpF)7I2Aa$vK29;$}HF#Xxw<883!^>e_No^AE{u<~#W? zusAt`qW=mw`d5UooLO}2Ud+~hd!Jp?w{7XN^q7=+%t3f*lK-Y-%6*F#xrCaY%$R?| zrhEI=i=}&3PE2`ep0s|`q|ntbcBNkWr^4>mr*_i*QLpyP>utxEPwwIC@UgC2eBUhY zjpQ}cLjH?KPP}Y!oz*=H+cOo9 z&wan{sjP6Kddt`D%X-18$1;<%jn}*Ey`*oye3@zfIhB*uOI&--tPO3KaeK&4cYpAr%xrdGUtXJC!9+SD!2(~}B6 zmRI}EwQ#k(u2TFl;OxGg&$@pXff}IO*g+ZPil*y>o}AVLGp+j9oSAhf@I;R6^Ibb* zS6Ik%>Ncq~9EhCo#c*3V%Y*toN}B2Rd4C0&(!agwJTTMNZv`t;8E70K*a=h}FfDz= z>%L}jbIDWRkB?)|-SZI5TBND$yej)mgw&?oO(z&m*Vi0WyruljLp#!D{w|$3t#5AH zdHKswBAzJpbNx zQ9Y>qWJF}$-9=l)SRT}GF-ShAHn&w`^^Gt4$IW{a3S2FLJ}2U-nhMjbmT_JQH3Xxck>ar~5v0 z|9rOEnEsh}|4!>*QLXw(Jb3p- zY5wx7`vQ93?R5NOTX)yB{Hf`a@8|!EZMA>>DF69Y|7FLzpVc{UU*>C}9Xxf-Q~%mJ z_PhBKfz!9%n_FMk@$UcMPt}{QRey2*SkohAu9IyWndi6vb7audRmHAp32J-w_P&(a zReIzkd&;V&B6-dO@{xzZO_eW8Ip#by5@br>$?@=C8_NR?vy??eowr-NP8IVle#bGV z_2R2vXC?o++s|HSue|*qN8{$Nn$P~pHg3Mt)3l}$lm*`Q^VX-FxmP^K_C+6GAtoSVO4?J?1C$3c$c%jnlkUZb3KQq)U zb53ec^1F89_L>rbm)mRZtW*gWe8{gFDwQP)9}L+3kE7AJyzP^=Ft=p-yEg~d{^6SQ zQBf-V{~vjiD-Qd=&bxa1{WjLS_V2zpN2+>oGktIu3kVZeEJ!=+`Y4Fy+#VA?ru4W= zA^Y_$xW4A@_{VccFXY;L=KZIA7#fXdu4TSEN3h|4;W6*6x4?P-olft8nbvuA+npYq z$v<@O3x9peSwT>+e43`_%Ch&&iJnz6zpmS}>(+%H|JyltUEln?wD+)|oA$@Hdv4l zdJ~gZRl!BC`vokb`4=_EtNY*X&WbO+t}nagRII3E`cBEz)eplTtXaBpZrj{F7d3CT z^}jK7eWUy9I=lY4DE&9)5Bt~5X8`p?PDFPdn7MwNTnV&%0cW#``&|FWdi_XzC6!%OhIq z?JJeLZ8rZ5PA>Y!msz@Xu|)cu(!6bwq51cpJ6t?-Ls++1{?F9ViHg&-G(GdTny`1{RKH_~Uswl^?&4y0CzcmUXML}votWG|spor#k2$++ zWt6F?c=fY2R(~>Hl_%UN@c+%9on9bP6?yNildC@0uXAeCM3&3<yoHPV}DAG3S= z%?)=w_ODwt_p2#e+B9>yX-&gLl`*I2%oSKJUN@i__=j zw5OW}v&--OU3smq>_)Eku6X+`YT35?I&II*XfYT1cVd<@s9ay7>-xZF23w;s%eg;4 z{JGp8vR{4ht7LZor(bf)wF6^Vh_TKVbVwqxBAGkKc#{b+f0 z{oI_)E!<`6H(%ov_UL~%*Ts$bv&od^0uP41Ly86`SMQEp;bEt{B3(e^U98W3-uN#T zCugKcrr%ASy5#z)i%I#LL$2KDzI;KRiRGF)!~e~8ynpM;K^gD!v&bE9rp*s>Z=b&< zK2BEYv5tPtK2x`2Gq-0v>|av}Zi4L!VmWuox9Pyl2aN|Je-%%BFDm1@kL79B`?7^f z*L4>7sxfiQNmXht66HMbe?^ArZ>!y)40bbp&RR95^qVhM)blqQ=jU?Nremjq=UD3BUg?|4VSMO`ud$D3k;jU*2 z2QPnbG?fL-Kpox{yW)kbek{e<+voKANrGe`aT=m5j}eyZ29@u=spTF7A8NftlM~P(tU1 z3dbD7PE(=A&0jO0{I`~I{l}B~>fl=^*JYLt)6al%+Znd_HEr=>S(bBqwkrLZXbPS|@(7RGlB4-D9dZ|MCHe8_D8%) z-IjBwoTJVtY~6Z61LN}?2mV*I1)b`<^GliK+#b{C{CXA>t!zF!DbD@2F6vmM(!>Rt zdhwS$ik_N33OW7mT;8sAYs9K;ubXw`Z|IS@?C|v2^Ql~_{}ihqtXaEt@{8Kd39KiV zi5}v6I<=|3bnXP_w(A%FcAZN2pVwf&sqO}-)1VZq$&}vaDPZAUZ6;{peR$$`RvFiO z97|sv_|txWcW%GeQu!$-`x%(he|~h%-pO&`|A|!o^;-+U3Hq84#~j6iZO=M?y>1q- z{+;mj1#jbKnFf)ay$5D~X%ou7;Jxq16qB{pZ$l@o=2hSO?52p1Xy;{<+21T1R(;LY zGCIF$Yh-c$+widY6)mg8wr_o&HR)^JqL7psYiDU0ottE;bZP#^iG^9wKj(a3>GgH@ z`;;|N9ny1lUf*+mpGnO7XVH>})9$ZcE3j*&1)G?jR+R4Mb9v%gGpmotN^-a?KJ@>p zPW7ZKE3;x1nxh2o-OhQ+@v-tw?8dbV#qIi+8ccZNb>P28-U6Ge#UQ_&7OrV~)?fRD zf0uF5n+rUqYr0d}9a>TTmnRev@7Mqu~ACP29&z{HgL;3Z8 zd2ow)nMqcSdF==5{0V+jh>T?lQiARbRD9D5naz9lN>Ue2Z+>{R&&>qld({=kB-Vy|J~sqIptRch9~< zSquMV6o|aKUGP_ON9>9<_u1>FPdvb&d3D2+x=ByxK3c7QFjLFuRFyZ|L@sT2={nta z@2+e+^I72Wl0~j=QzM?O+Ob%M5i}|O%p(!p#B-mK8*cYwhiAz5Q`U8PyU)(*+LSuS zcYd+)%m2?lKbmb2{y!$=lI5%^y{DGXGw;osTYB=O%45Cjl9%7tOuH3d`p!4feeaiL z#V`H|s(0tB6+W%b6POuuJ+?$#yi)7?oIS!{%kLhZe&f}5W97wf){Ecs{CdCo?)*}# z^i#1lhyP!+nB0H&_O!Y@?St}>lR*X|6eI28)iIur`h>Wo76wfzsgS+%niTsxdTd(D@Z<=mV=kTK$*+Oh;xTNa&m z`(5REc$WB)@~iB+g3GhL)|I7w?YXS;TU&d@xmAyk&hGZmxz6NJlUn*=_WHoxr~K_z zB~6VE81c{e>$fN-g=NjGtQ3xru+;p7o^Z41n)bbE(1srG~>L_>ex|{Qvr|mMz(@ zKL4FfhPKf`wjT2je^rlVMl8Cq`JZs%M8&%{K5r~1_i)<3I>_+CiHYfhy^uuWR<5|8 z@}G0SMcNsay?gh)-Dw}t{8ZQI#xCI!#meKX4cp}qR*c{|6qr>4~@igj1ev})m> zj5z(6dDVV4>eu6{bvN#Ov8dZa>9Nl5#fSL5p3+{wq|5&I&GQ~|H6IS%l%Dl&SL})z zyACc*VM*#NyOeR|PqoLfp1lsu;g>gyY~Hd`r8h77Rr!KX->j(bYM&ba6OOt;vie%+bsX_mIRlW%YSbN=@A z%j;+7*-q=WnfSr|ngqD2@zrEXKP192C$(Nh(BkyDlRmy%@V)3HKhykWH!YqTyvj(}abvc2`1(07ObVY0W!==2S*mQ^J!_7wyu2c|$l$%`WZUG1{Z`Tc zXG~_Sl8e^w~e$F1x?4b-cdb=AYTg?eYH_ zmfV#$w%a{-6`yQm{%X&ClIf?Xca$3DpE}>X&1Bof_4|LH?2Sp$Jf5rf=jF}tIcb&k zKW4CL-7jkI)qNlTJKOO7?|*9h7*iuNOA}+AdGG!@xZYpE@@d7#?>V7gA8b*}T|2kr z@7X3%75ROCB`2NHT)XKgYkldrTfdK*N9nBEq19x+=@@uAD$`5QLU@_O1D)T>od+Vz z?8@FB3cR8EG~af;m|mW^*1vOnd!KtVfcgoaYTHtB`2Q=nhk^p)<_=JE7nA~u9XJ{{ zm*|1ZSOFnWaXWL}gZ?!q!SeyLE-gxZByx7w^Lc7>AMJf)VwfpXvf{)ohVzEUgHJzs z;MHy`akcld?qXY|$2#}ajP`pyuk#c=)wB2bBW5MxUUtb9B9Beq>woj|3hOp!5>yuM zeVwb&+tc>w=;xPVe>XqsJ@D$kgLDY*B85OLu^`_$C(UzQ1hO>jmj$~|)INNMhea|y zP3hY8nPPg+ZtXO>a7QoX%;N`9dQ4YWyQrm?Za!f%?P>81YcHkCYj*pjIIWjb{kG2M z*rod+)#h?llNuTR^z(#8l&@N5`S|9VSiS$r>)wN^>@>BRb!Qtl-=6o|_HM-!zK8O4 z|1ZzJr2TBiBc6pXi}RMvdfI%{-15xXl=Bw1Yjzhd`6RmiZ_kso+a1}TZO&ZxOpg8f zJifDM6?@*y#C1uPwqK_-ulQqL|GV>PZLwl{P;6;l+3%RD%a&KZbWG+qC{L`sdr9S+ z9cUotX6Uf(Yz*%gx)K5xV8n<1s|(z`dA?fky>^^1ScZr^&G|L)D->-NhQ z8S`(R?)l8*^Ld+jKc)Vi@NtPz26d8W1+knnn!(h#*~EZj&eDekwT$i$rRFZ@6}0#) zvb=1rV!^Vxo`wsrvOK7NQo#d`XoZ3_wMlig=XsO(FTK{=rTy2fPn+f3B}T=9wgZvB zgde`+`gdZMG^m8C#jD}LzNXUsww_x~Ono_Z z^?jf3!JfN>P5o4Rr8*N&9{h7GZqLQ@S88H+W=Omhs%E?*U42$``<6AUl@8`-fA6!- z;L0>M$Xde^wQ{+Wp8G?S!)JULAn{rzuJ`QPGaiP0&#EkB8a!9%N{jvPRtvbN`pu-% zrH8*_>+-Ep)eon$Jt~}a^3%rSlYFM|D9-e{`87I3)?Ng(cA*`dse=~?TAa>{U(b}z znY{jS{`-%?QU>8^6Czg|>xsOcEFV|U^iwSM^sK^T@s3Y?mQR|LC;0y8k0cg0nuw7d&EjX^K+8vr8fzb9SOzGZH-8bI zlIW}A@IdD<`vje%N4cL*(kj*8?-6vhXLHEJispOUnmby`-|pG1)3N!L;9Jpr39WlV z%N4_FG{0Rrc}t^V_MF0NB?nTZ_STv7`Pp61O*gi*FRhWXzN{hG z9oQUt_`BlG;Ei)P^94j_GJUWwnsHwHe=^^akEZ7@t+PKQm+o^WYS#W$C%4|KE#>?B z>#?%_LnZf5o&I@?e;s!3?upOS$v$?Iw?EEJ>s{RemM~NOlgn-8ES{xA|DE&qcJRu3 z<>y70SEOYv$vSrP`1@MVV5{8!@oBT9rsi+HoTl==?&+*GU*FuT`8zW*gZe)YUu_9r|A(Lblm&C|`bhcRpy5xo zG+jXp;TQpn&*qPL8jTBIv}tv+aLkFUkIRaW+EXBRcH_IJ;vTDJ6&{;?-CV9v z+ktmZMi^O!JmtJJOQg_GQtMMj@pX5ts!Z1GjHde+ab*j?uy z)-N&cT+ekARFJ2dJ1;vRxqZ`acaiJql_x$beg3|Ed-r9{oiW>bHhZ?eb6OQ!y?^gz z@k+U}r>4*EdxgH=Z~0quHmE1`>hCXo`%|~Jt^&0Ub*?quzxVjgn$M4CPrs}5?S}QA zC4YCEnEK?hpLF<^wA}c>P`~e|PkC>?wz)buw7%qK>hy~uf)<;zGta(KED-B)6tv)O ztG=*!)hmsrXUk6gC==IObw)j>N~u81th+sX-v|4Lo3_V-2EV>NX9879*K8DZegCD+ zQ7oAD_y0njazTs9M}-()2JVS)+3`9qGs>z&DmsT$<5-#S%t?U zmHn=s;oedC@KE)vm6K-8x&A~o>&?~h)HA`Gb_W=4KfG0j#jEPsWTnH?YSUvN{s%5sjt4b{z zD!0@aAIYo!*MH^dkGtDcX6Dy}CK$e6Hdu6SP1dcgcMmhxmH$4!^6r$!>PxqNe|0GD zK*sjJNAG2s+m%ILTNrxQ;_;dFf8FQ%e184v#W^egeLvS4^DmG0|1ohwvbpTZ?MtSu z`l-w}W24KlnOFL!*Pj9v;4|kg=WaB%o>Fu@@ND3dc@t-u?uptlBfbB?%$Xfg*E!~_ zOx4eL-NW#w-#D)B>bFz>6$+l6apIV>^t020H!@5`M^3UYX+Bx-wEUPPQ+m<+!efla z(>Ug&{^(y+U08F|+~nx5=kt^#-@gsbK6Om`>$-``9`CO?AC*fNTx@eaB`e)$xzgL|d z#*3M=-Yh&^eZth&vgeDugjz+uL7+&AYC_zOB|g=+TEG5yy5>U74$IeQGl`R-M1YQoco< z;lFZw=iX2D?D9I_w4Ow|`qqEHRXeeHeU_19_RQC-XWe;q?DRj^{r76$9*#-z{-oJi zzPHBoYP?Opx-OaitY^p9wogy4&zooWD*e)h{M5JkFEl>-E|8pm zZ`#&*t0dEHR>sf&rT*({>&yJ2iOQCF#S;s+eOG*>z0LaFPTj5d@14k4BdxU0^XZCU zd9Brt`t|nzDVk-=ekMYx;Mrm4g-+8!4ezw)maY%Z@GZSl8d4~-GksAR?|Lyk8INSC zGI6aK`5@3r$djr38IN@tf5_)o?s|Jx`+xI+na12drWPKHQYbj4GRG4%0mTV(ylC(vJ9)kF z$?q24Q##MIhPrp0X-)C{wpW;gtuDtw;rgMYcMhz4T=916^%dQolmFc8KD1{3s_SLq zdNLA^ZBzC{typo&w|uYmY!|nJ(#yw9LMLvY`hU&OEl*zDa-Mws-=zBInYJ9g>^$~c zbKYz$F^^Qbcjd9jS(Au`yBq#bJp2UQ^PNx#YRtSXf3Rlp-flLVn$wp(Z{1zowRO)U zv%+1^%7o3AD@7WeORFifera5?WWVI;U^Cm^W0_Gmos2H`n@h_tiavVuTDAY`d4=1O zRz^&pU--h+%Vc#d6^x3Mn|ae z|HQ|dA5Q(Bk^cCa{KM6=(k8vUUbcT#=YdEo^X1_jb2yVY=0w_CI;sjO6r|<$Uzm1m z*HP(7yN>s8)K#|!=?FDj2z$NBowjD{cef=Otv8$({ClaMv2WwPy7JF`$5+HYzp+u} z_V@WtMpF%w^o@9v&iaO||MybeQ>?UX(v+|6^`WXOuUUkq7%Vwek#8PpIxSd#Ik)&{ zogz!VlsPjyBjeMfc04&{VEp}A*}|lE?AMC~G~NlnZKc41fA%%DzE8saODNTj!b=99uhY z$8*(RU%TYv&%16*iFM|^y1VYzQkDIizgKR%HB-BC@9~?rb^9*}Y@D&b?#Y$Eb&ii~ zKK%=SrnxlM{$uBj$2mVTCVI{-EB@O0N;2I>em`@D4d3ZcT}HpYUYYN!Z29(g`-C+s zgQU2|?4Szejm;^JIX4$h+1;nrr}W+D$~wr1g==orudMz9Gxu#f zdS*`goS8Ep9_^jku{(SHpZ>~{?Vu{&_~e&t(AuZy&n`b|SEc<57Vm_#oMSaW^_A84 z3-PYMk7TNa>f1T%oD$a$>+*bmf$h@vpBGlD1gkFH_C^2qhuT%)wy$F+U2JSwTQ*Z` zX^**~FT39|t=h*&%1;>2eRpzs&a}VpWmm=j-Ty!JRaDofcY%+lOuN?1r&{}_Xqr=H z=aduX1!?oTZWhLrOuQk?SUqQ^=hFDkhn|9lDx_zAGw{ye`f%#))>Cb^3tiHg{)k?< zb*w*QR$Tg=r9D2Ir|r^R`G4ClW}Z3!(l}n^7|T9q`>)&{y7!a)Gel?4ns<(BuWh$w z^Q+DL&z6TlG%){-CP#Wo_iw3%ZZb z*xa15@%yXi#m_FTHJ?}A|0LV^h?U)Sxx#1vieinw*YE!*zRIfV@xz_7ZtPm$W$*Oh z%rr~q5Y9$pBQvJ-oAWDK&Taa)a{_v zH&0(~GY92{8uZ*CQq{lt%kl!yh~Vppl;3AppO0iYw<(bCsqM#SyT7bHpZRU;`h8AD zSvBtV1}e|a7+!vvcJh%@(Aw1Vdp*D9ZS2(zT^#%9%c+I@_q@Nx&R;e&amzVhvt`zA zf6Mw9g{OtpOg&K{ZREVfsd}wHn`y52`n$VxFWFsLmfyX$cU$1?+69lSnCkRnuJyLu z=W;yo)@iB0uEd@PoJS@p9nLvYvr*mqV#Ddnv(0C8{rK}%Q+K)bYPasoeZn7C@n$Vz zV41=2hkx>gVDP$@)Oxu)Z0B}d=l0({tFCl+y5#rd@T$3m->!(4U%J41IQ#9Y2P@+0 z|1(^@zxm&eHE*}w%rxFr|6Tif%G%}C$0DztT)*$;#e1%yb~V3^C%r!_QoZ_IO;nDr z$@k5!J9ll^v;X>(&X9lC+4Z;WdA}t(`J>#6H`dH`p<*oyfdUk&A zl?$FtiST%Je0r_3vFxWelU16})p+qpxm2l~I_+_+y2`_ClG26iw-x*@=iIJ0n!4P} zwKh=m^Z7@vpI@Z?3Xzx zZ;dzCOdU-%TdCzTenkm8EMkMXo{EGpaf`F|PIFD&&OKEp_R%yq7O$$Kv+pK$^X{K@ z;{BI2x5+)-dyS{}i_AP*n>zcGu7|)04~Bp0%2K*{;NhiI^ZI>_o0oEjUtn<0EqD9Y zXtV2e>9Lz%`(vDDy)C@pCzmduA`gRhqN=`e%pwe*z8<&d9HOaaES_hkW^+ zkj$r`F`;R94?zPy5FGF_oeBlhx&wZJmlixYr6BP3<+A&2HhN0uUDv*C-1PEJg|gKC zTKkECVsFjAwHbcv@SNJYH#W#sN%_>eP0jZ!C$9>tvuIxuUcK{UT*B%;i&?*9CCttQ zX#S2%_&D|S%6k)*%c)D2EStaZOVq{qK*#;&a;o1hcuwVytLEQtq2AEiU7TjLy5*5)pxh{(ahr$J5_z>}^Yr;Y^nF)u-FUVrYjMwIo${OI?5BU{aX+uqGkUy# z`~RO;T-Tl1{Bm>tvZ|l+)~y5=Id3N~t+Zx2_sQ6RDgEb1Ztv_+8P|w66W`n1)eHF~ zEB^K>XiKqF8CUnT5B62jx1f$}giM+V>^D9qU?CiBnEL;I#E;z^b42Iu6jFBU-5Y-D zUiIuRYv<|So5H@d=JCGYmoL=L&Q}dFn7`>|(8eg`#p||g4E&PtW~SIvABJgWzx^ev zO8cc;b)%NcC`LWIal2=?ew@kty0!K1BqznC9w_|(^VGMuE^f}x*Cn0b77{ZjZC3vN z#N7VY?zm~?y8|wEGo>Y~3G{Z^Gh|Qe-g{OnT0Cg#*1BVN6poi_DH%<_on^LY)>`MI z_tI8&Z0>n2bNIowXDp!Iq#HnU?~&i0A6k7NGCZB1x8w|8lK%Q-Q+02$msMVW-D7;N zr0U1t{S(~^lly(Azgn9+%}4F!cjfJ()%({*Y|46TyE^_~#d+3{ngdtvuFYGo8*KVG z=hU+07iElW#moFfW4RAK)ZcIaJ}v9xiMQudaw{+Q+`ss@bMm&+QTo??!FBFV9mz|- zIp#=C%wsus=^s02Ynk$U?ob)md&0b)?0Mo^iU);nWhxbjMP2r`E&L(B^6cd8Mc`WL z=0)WKvpEck1!~(2X6?DC87V(w?=qhyzw2v1JX-VhjJcqN^Rb(=(v!Y&hqX`S_&j;a z<6CPNpSYNtkuR<*zC7FPjKFdmH=#$p4htKWJW5(WO;o+}{U!C@y=V4F+22z4TdNbb zZ^|y6e{N^WFPMA3*tKM3XG%%K^W4AkGmX!(ty;aJ?y{oNWt~XPb>a5ELgu|>XV*HN zzSlK$)w44vLrf=pDvh_b?K&neVWuQtTOuvOExuQW>;K~EwtHvyevm&be#bL*+P7~K zvC+RL`#jz>|D{}vduUj%K>SQkS8roeM@xL^EmqDzYas*$k$JIfilmrNImbiKX#x+mur9O zHyoI0FVC{df~!7lef;`7ajjny5A&pyB_-gIxb!Lgt?Ds&UNWwBwlb zb9Q0dftj1dyKnx#UR5sMX#74R>t^tl-QCYZ{~u9S>5jh5Wp)0ZqI1TcXDg4*F1`Ql ziqXI3S^wsL`qMpKPtDe=*JsP3vgYQA4mWQbOnFzHzhBwY?&)N`oIf2~&4SlfJvKSJ zsLxM&Em!!`{hONDvgdSNHrczRueE!+N%Q;DZ=F;t&Ms`6&+>?KWBJ(=J0G3h@cFWd z>#?HxjW-VG9DT9a^KN6(!}32@WNTl0GyHRpzrAke%;@?l`44==R&BTYT^*EqNG>{h zm5TQ<$$G)pTmII2R@Hs_7e4LRF5NHMkJs#3)DyDn=}T?TJkvViaILy;2d`gwcjoYs zaLMAik1AgJJ`eb->}Pl2T7_f!oZRiFr!9FtSO4F|Y5QVMzI(Z7`TtieS)Uen`#oA# zd9tr;<2(Dr@Pn3@QCin%J>eqg5Nz^5nimWly!$N94FxUJ7zv z|E9WAa;gkRu-dGNiQC?pm=xNJidJuQ&6zjvB$vx%&R<*q{4MtgJ2%faXxn9z=(5|r zUtZnIV>;=fqo-2$%$0$A`koJ-*KfRaa^2PGpxDpB@ju|+KgO@-DKl@ZPMQ1vglW%* zbk4);*GMRTJNHIgcJgY)cg+rdOdsqm1vO(2f(zr_H}~?*`IsTU?>Fn;Uw@g;KUeZ! z5-*v)MkZY@_Jeq#)eFbV-$1=**BDjbq6G!lvXf$ec29OmOIYW#dEw$C<`d-RD*IKR zxl(^CVYU9BQx>n^%P&~?ZH@gonV6mR`gQP0{MztY zKd)ylmv=I+uX_63W7^cuyDW-le|>MVHf!nW&wamjPsYsbpUii+^;K;3f7jRLkAIbe zOS^TxGLzak=5RU}Kag%TKFHi?>~F^qQYbRd$Bbp}j@T6~489)AI}Sv8@UB0y&-z zi`2=+7lYgwmapH}7xMB}k@Td$$;T%8Ott9VA)aL?sLT*<6u(5{`o3>Qyt1!f>(AgS zWAmEQZOaoTSFIH4&nuN;^4V(h`98B>R|Ag*zHq-cUvg*ciXV%4t9a)%oX}Vo`%h(M z&{eH{pZ5RSAsW4E^C{{*Y=|9BMa z&Tm!apJiUQIZ(wt-)2tA9*>&jj_-Qae{X(Yap&ddvh%MWiQLdFzcJ(1^WV;({1drv z8lPu1%Q+h}6Q*=I=FrX7p)#$rO1Rf=z1`~d(DZ0Wt75}}$V7`|5p$LY^>+e-m-j+4 zkF;{ZG*Eop{0W+FbO)I=bL-!LEF}To4pxVsYyMxFyUH?2UoUv;0X<7*-9R;?C1J9K zCNC!$eRFth{>Sj~lnWj~dp`P4ladT^W2yUU>GJQK;?yPoy2a0`M}Gd5t@1clY4Q1g z_sp-H`7Zx9I^Q?BI0i3%>ylA7z#;1I|1p#IY#_1a`mS84N`WnZlewk2gM zYZm+nTb>?%$>0ev*_byMb+tzeG{X6yatd)Ou%&qgel(}qP>hemFe_vL;4}3kjz9>X) zZ_bSnPzc>@+veQK%E)p~XTc6lrw3=Lrc%-q#wBfk0@qfqfdAAqK^*6i=QEE6a z)9V`7{BNN&Wvn>H9jZe;kd*yTy2GZ9lfz+*18! zCjIG#v&)hdi-Wa{_Bu;@%wOibjBD?YExwPxoI04l*YD}mZm|h6D^@9iYKxTbe!D06 z99`?*yl}Q(-G@Xbw`I{c+vNjZcj+I=)Z7?1aiT)>vYQT-mM?EsT-m5HSyc4cn%Z+G zjNLf91C9o^9F_Zd?($nF)%%4P1IEo88e4*@4{=3K5t#^Q*+O( z&uZQ(mE=dCD-QqCU}XFbN=2K47cPaQqSwD`@0jg6l3$(`2sBG zt}TD~4^ou9%au7d?WD@t-8tJVS{MJ?8(AV-aV#Wc&&u~VlDID|uf6o|kln=@6PO=u zIrU3@+2zuct3nsu`EfIQ&Z(O{C z*|<$CD}GOn0S{l1`uKK49)y4GF6cXpMFNLEaoaWGIV*7Sm_sQW_3 zS4}<#-#RT-IF&e|Nm%1uv{!Opgu)*`HT#p*vv@_iZO?@D2}xdNUV3;%tyZhg{U&LB z7Uo0$U*x82|My~7_d6AX?{_}TxYY3P>#g~o|JSPJWLfsJ=U2}<_5aJ}Xr1@zT6c2) zbC$e5c3kgt$kEB4-sNqaXOXx3|Kfi?L|m57wKso1|H_hWaW#k4f`5ZH$=u!bt?9Pz zY93itzs5NqU*?w7pKN-nvVQ*$y-E7E-^4t(+gH5b5+7I0R$6$$@KK1z_P>R@m%p!{ zTDSK}d;LQt@5-xl_b>Q#^7y;lO8fAK+Wz}|L-lMQuUC1S_UPtWi^m`B>)w0+`a0`# ztE^qs_u_p|SKi5+uDf#o{Qn=>RkqLGv2*#~+1A0J!Ns&~GV3-AJ3Q$5p-?dGui?Si zYL<{fk#A|uQF-E83d_|QSn0uy{d{Hdk>m?r%FU;LWF;+BT-tSHUW#sYhq;Jtbnf(Jp!GHd z|7}C$UdwyzzQ1#3h4R&X=Nm)LRnIA8i#+?;Kd|!Tsi~RYErqp3#U&%>Ogtu;8kjuC zXC_;w7fWSg%B-)~u1yfjHh%BbbH;kVzcg>rJI21XU5hycCI&h0j$NT)>Zy_4+;HN| zx}3VXudc0~GkIa}2^DXtf5}HXm{-K*@2t2yd!b;L;{jo&5BBpCrfd`bp`7Wvcxlq- zeGxvNt8&{;?1{4RPg$ZnQ+r?KWD_C%wqCL7Kz`|1|F7lt1?rQ8zuo9c@%fm)Lq&*l z*70=tGpajF_B}8#P&jp(jepUnUx`~)%XyeQ%+AyOa`Ugl$Fs_xpL2wMd)N*xz2tl3 z+w=r2gd43`&K>%&<+r(es7z};%a>O-|FE-tv$%YE-?#MU12b#GR!EAVZh-_HRn^4I zl>Y9Y`-(^X0v5&T@msn&4l{L4(^C}tdcJ!v)3vlBQH#$ykxJj%GOZGfUK(Fe_}12W zY6;8sUy~S zU>P&({(l!A_h=^O%i8Cz?ppj|a-&~u^1?-vP4Avpl@gS^$=m&YvEQj(NA68~CO3J{ z@+TACz6`5=tFoBOuKetpg15GeKjh!fJat9+&qSYV4SEU;OgA~eZa$sndgrtgL*r(h zf-5^)4@5557IZX_h0Al{!HofdCnD@tbsd=5UvP12I?IE48)wZ__Ydx%?X8Ur91#Km z;1r%{BVwtjz?6~te!q9`s`UT;DOY~zZJ#{v)5_UKOb(jL#ae-3Zg;=-$hdCWv9j~Y zf~SvmKeG`_kJ7b&84?&0b!xlI-~P18Ybw9zPTJER8Kv5)ALQm1cl*ajlkRnM%Z<~T zd)l35go?Tca)?>=G`4o}g`KJA`{<%wYLs+uzvr>uy|LH6>e$8KUe#KkC0Dkj&^yIr z{p$An4hQU+KG^@;ICWwA4`sfz^vDMu_1uk{6MUC+E#{au z$qICKQ{UP@S40|(?O!g+Uk_Ra?izHi?m_>PE!|&AhqoLgp-NM>MBi z)$iDQ?$k7$g>j3d_hgn|-dUgfOIv%z=V#3PiZj=&RGn!gv~E>g<-B$5=i?oWf*$O> zePqGC|2~q-uYKW@_L{rhM^KV~<)ftc?9Tm?oGTt_82K@nU*bquIH^r}i+J;{BP+w4 zr|F~mr9~OH|e3Fo2!;&Z%D&s1U6CwjTvt{kGz2~N$ zT_3o7tG2yLriy|4uPzt2_oocqgDV#;X-w$~p7WB+-eG|O==6lcmMa1u^fxK>7lMu> z5dx>OXID0@m_HR1Wgl7Ug&Q|N$lWlHZIwmW*N|iDBSFE%>erE{SRkg9tZfteL%y{) z-MRFKvh;)iRu%^bE=Z^>sfhU!taMMyXlvH|>MphLJ4$|%m-Clpoz2Kyc5}*M&yOj- zbEAYW_(X@ww5Hvtoy{kvwQphQlE*49Be(wvoHTXq{RuwDT+ZEG^>FfwUAvQ_Cr$J? zGeJNhpn~axeQSE$3BiZ`T%OX&jSL)5oE<;~cBEaH+wC6EW{3{83(_2OSi9<*4~7e;mD%pc0PR|E*MfKE^YCwiv3fGzTJjhh{Odk#b{_)*Nau50lL zOTiEYric|Sza1SO=-l}hp8M^C{i?__GxvRPKUcBPjHAJ!fCb!+`)1#xM>KQy@;a~heC@B=WS_9G&Vv&&pV@w~sR}t7 z_{8vNfay^u)sScR5;i@}eIFF*dV9G_c(2X86r-?hj6H|KS0q4t2 z!VUj5Ej~5zany$d+>zj6aZuogB<)WIMzeM)B(Pin<=GvN{+4_2{d=W7>0%?J_}oRF zt9EoKY{}nhdUBm?nbJ%xZSjH$nQ9gu--1Qm6(!9wuO4)Ajk+x2vhmi_{)-lKuL^s7 zX-_*BW`0W1RjQAstIX|1Oe)kJ4 z6xr8&A%#IF#KdsP=}d)!XF@WwSe+RDsSCVel@|H8B4LH007rwvJVuM?88eM0;%#e!$z zkzZcRGyahOf8*2z<3AI5!W#G#7?{c+8Dw*^@0E}99lYkOlFZLqvXSk);LP+?Zt+W{ zYwq9pt+PZjLsL0mnwH?ql-T6sLetN23VW)p&h~IybfQ2&;mVJdU5f*n(>_EcEfm_r zyMU3Sp~05vgZ(#)l~LUPJVH-9<_mDhGlEAd9#)$@ony$99{INEKxCNBse2(u12wpJ z9Oz137?kipf{(@FfGOh-`RgyHWN_3kX_>h|g`?p>79>G@*x}`=Fw0=yuT2ShFT+1x z%$cNm_N>ZcH@2vd%RF1QHX1$Nat1W0cTe_qzxqrGRikr3g@v=!L?5*mff}f%8hJeu zbwZe!`V$=)SY(77{ulQ6tYrJ(?lqJ9RFlIBPH@|S`)jh+`zL}H!n1#U&Ya0H=j87- z&lbQkC;*|t(wOe=Ad*)uMtEa8U#fw${+ZfUna<;wBhk%1*e5nRpqOjBp8N>LSf z`+o6nS8uV2Yii!d7T(qLyWhj#aoJLtb7OeawR0)EPF7Dp>asv5GREl^ZwQ~-&26XU z9#!4&SXe4(bcTh)wrk{pK>MZ(09&gq?QK7hqsC zhJ4#BXOrW;4+SBHl%Y#-~blEJIob@bXMa!`(Gc-6fK+Ezgh12FT{0R)R*}=)A zpdbj*DP(IBaFnr;vG7#do~^A?Q|pb|Q*QpLR7pN2mw9o?#Kvhpo#&Wu3HN^2={|B~ z<@A*nr!8Jy-FY%irFz@lc`2I~b){(Oy}Qg>m*MUp>b_96V!m2SYnOk*MO}`DhUcKB zmAh7G`v>=+narme7&xE-q|+@}=$*vGF~>7UW<}cc2CcqB?`jUk@pDhzV9z+io@0eG z!#{V~=$#iu{+-}8-NMKyz|jP0pD4`Z>v<{NnBmX=RW?7)byLbmr%9>ZNA6`D&0U+6 zqx`wY|LK_-9n)U6Z8V?xBImBZ-ZLKKY0OuzhODf9?CbHZSHibw!7@J+JzY^DduLY% zQFlSD#ZL?)bwWNhv@Zjl*{RC#KRDnHt31!Y6%S4rDsVJ3cta9ijFiOLCn^mGX3i;V z47&zOOV@5KzT(5cEov+n1F9mt82-73ovv6c{%4}O%tdaFhKB!;BH`zModgz5jt<*% z*VwK_&GKA5V`8J&`<&&kcNBW1uL)0CceH1HOVK0A^eL*{-7D)hxukTdvV~tTJ)JW3 zgn{>!uAWU-ZuJ-yR;h&^>%Slj8X&kAV8)oRFiBWsRU-oj3usckK={WhhCdV6S6sB@ zXlQ7MSX`M=Qle_i$Z{@an_om}@X^4Qt9E|Rdt1ALlUDFEDF~po`oRaEfJ?z?``2D% z1|2lJLuocx{$cdviF>s8A-e`)TYktaz#0=gS2 z7c%`{JnioSU5=JchCluHuC>8WnFKc>X8QTdK4W51SRld}D$^Piz#JmRJ(b~Ma)tl{ zqdUukdYOko>x}+PEK+Meq#zIlDj44~r!TB!GAtBeV0_A<@Yu(4@>0#5xD`w89$^I) zAReHzWRo862;lv9g4M)?kx^hmQ^Ok2*s7)+zXl`dAnb`9Yx| zV8`^q{?knr`Q8WppBA=nQ($0n6q;~VK2zM{F(+$gP7g0gTOLPD)yt)MvljNMuso<& zF=V;Iz$j4Q!|?C8q0vg|1M;4^avJPR3KLoz*8Jo zKIN#`4pxv!K|&v1H;11(nYBXXPk)=+(J2lJAS1sRnf#d;2RfQp!G|T~tNWjwrx=Z= z)xCUO{XoZuU-TIlNH3P-v>(VbvYh*q+&#|`$7$RmY7GZw9y-?i3X5h2kfHws9Uh#q z-}XWhbUZYc1H~N@nKsV(-NOI{cL97SmE4YfBex$3r*Qxx2u72vziz{Pyihd zy@A>NflkY@<5L_Jmaqs|1YiE}X1ztiv7oaz9kK-&8V!UVoHt}ScPOLwOr$sy$DEf* zp7Wm-embcheE$2r>h(`KrcRw2QgXBjM|xpPxPNm0piknfuS6{@wYx*3)<0sCeAFxom5~L8e{* z&so2pVDsyV zj`rLX@45QT!K0k&a~$@**?itk<^TKt|IRnZU1a5GaFF2q@O)0J{gbQrKkqrW^Xs)} z&fkpeG807i-^~A5)oQ$K?Q+|Bk|KE3e-M7v2C&uinsa&>d-<1E~_y1p7C)~+$ za__fWr>7TQ(B!aCY&bAeI7)HNX+~L=b8{r`YrDVB3HiOXvJo_VnYMzHNnwIK<8p4X z^-R9cSV4y@mZkg#O`WlWPPcv~{NTJL%ehUrg&H@%sVOj@!y;h8z5H(O`Cm&W`z_iv z$MM_m`~T;yn|xB`q`%$ImM2R*CwmkvyRyFiZ}zT#_y7NMFZ%J}p^AHtgySURIePnk zB<(u)-Pf)(=`s83zrS9u4}RMztUgIcUw`|>TXj?G|9zex^7Bftzv{`Em7kxTUHtmj zuU}ivSvXjhy@}9DKbJYxW{<;zGtM(!aIrWX5Mzwn5L0~CH01x3V84^M`t0?qtE*Su z3yaR3y0vcE>^$alf18gir9Usv|2O5y_4xX^nHj?%lJW-2eB@JpP#Da)k$wLsMEe=rN_g z>v=bIajALLyD*M9Pt#hY6d0K92^H*0_^5TE<-kl^>rJ%+7MqWAZ*^c`xuSevelf?K z)a|lN={J8q$g|*7C=m18eS4lM`#Fo?H@C9a`)1GfTKb}D_uFmCmbte~CjD79>${TW zn+=Co%-O&B-|qW=`_{Sl%lS_F&TzZ(`P`SY?iZYyVc5K`{AQ~7r2W6Ht_SZ)f+?S<0?!Xd zCWQwL4VPI@zukV{Z?Aszww@=h;;}B9cJ7?1QonES$79kZwGSHEC#_k#R(0q7y5AG; zl;5xQ{B&>HAJGf!aup4ys;jCzK0TMdZvSFI^NKn9Z&kk8c>I#7zDC9QwZ`XdCZD=# zUsOM*@@#cY+ARM2_Y059uAJX4SJk1CUQ+)$`u?KV`~Q7iuToi@d9S(WuYB>N^V#Qq zJX^2QD{0(zEB)8OZF5HyZ!D^JxG7x&Lg>&!68eUuW^L{@cxT&ri?g*H>E{p3X1);44@C zypPX!-_+lD|AFn2_51%-z52iR`#t5#FBjb>@A-I4+Vc~4$*v7Q4%YR3eFQq8uY6bB zEZb_c-b43&s(!s({&Lp!aP#{$#<834KhA%%A?wZmO4$>2ZC_L3&U}Bhdi}CxZ}ji} zy2)?AKDj8NZnujdOippFJXA;rt-y?BpYTv&5%y z%&GkRpL=hE!we3K*bM~_+WreXwVxws(S13&`M^w$6-otS%lrk;h_ZZAesG1q@~e^z zXmR~Do1HQYN4e+z`6thQRjP6Gf)mczD7teZt+sTmzIWY_up36{*g_ccC9^VUs1BN_i^*_``6ck2KDy>ebWzQ%Eq|JpgG*xK4Viks zW&hV}(ItC-zuP_eiTTH8w&!--dH2UcxL@|mv)TE6e~tgYUbnk%ouzZL@wsVwv0hJe z#Os2OE?-vrzWV;+-*a|O&;A!8X%A|3B-k?fiR(;{tJ2(>R)1jr>OJ32X0PAt_T6&9 z{|C+dmrUi)9a=tpqx^&Oe|5LrXnOTg`t|hqx|!>K`ql&o3&(zzE;#ok$^J<7+L^x} z2QRgp+{;|Q$DO-BeIEaB)qgQ*f5X>CxmL|7zgMXe>3?oXa{Z>ue%2TNt_)s2>Cff) z|6C^hHBRr?c(3I0Blg?v##}e@H`>>JI;p-=^!xFD6Te#||NrxO{{K0zKGs+oH~*EK zuXi^8)lc7l-uio8-rkru$MKlVO}jte59RHC>-M#h|JvvC_Vd?O@-G0D4JBtA!`8XC zf-%S0mXF_n6#xEnY?kpd3*}DZRr~&Y z@?Ld3f8WjfGx?`UION6P!JY zj)|u)`?%+R<#X9x=kxbf)-0DyuX$zizW3O_xxXKNoGJe~rT&~`>XI|Bqcg?ce1HAw)e5=v4TWk9uaDc9-^x&4b^Lw$&ikNU#v$jbuUurUYhd7L znbZ($Kjr=X&wq9vt?Rz7I%nfiw+DN5yuH0wb{N;6eLeNO-S3`vv;PX!&ns#ZNq6{M z)oHxaGCBLS?)E1Obye4Q8>P;%jMe}6r!qg_-s?4c_RKl(4`)XFH{WdT%_%&VzsrKT|Ml$D)Kpc`k8$kJ ze(zvo6kurdVVR>B@#FJs=JY>l{r?%Bo4?qzowNV+^6X9jjnd~7s!7gon)~^ia-=$Lbe>x)U9}rx3`Fvfk|H)^zw{FOPw3w55c4p=r z%Y#+5pU;|yOf^2bLw5hi=R8d3deZhE{c7ZCcl4LL{jb1fS{m$(0vw!-|K>cl-Rx)i zbjljz=jSuy*gyPgw*OxA*Y^BtLu^%$!&DH79J(2Zskf zGTHs0Vrbsq=T7bl0!E-Zx|15er)0Ftc1{W#s3u`0C6t=kJUa zpSg>_o#vRc6m%lu($5#Zay1&q>b}0X=MCGrA5+BbN?Yy=e3<<-Ml!u!u1X{GOg_&K zUaR*uNu$MR==;+<1izvuednd$R9UDteH;yL-ntnV_(wr|h%-@j9EjP?D`!})#{{bv8X zxYM0J%&xF|+h+VQBL1Ftga7};c{TDvpk8{BV8O10hxQjN3y$SKvD~bA`MJ{jyPr{K`!9DaZTb|zE0kKXnB zpWke^c>D&meQufhyb7gq$4TECo}CXrwdeX46f`DjWJ;vL5plP zfjth656;Z~AOC`t<&X=wPJ7_`z(Ssg2RX&R@|#(`X4u&pgbRFVYY4V)IWV&n zv{@U}fU^%;3%dQ{bR6sTY0T++!nx{~&k=pdqf^&>XnD-1+01<(W*eWg_;6@>OW`xo z`OA*xxBcAx^zw55<%*WV-Qq!i(q@Yb{kdCbceUrf#D|yqYv%vt<=Y>*@ij-qd&Xx8 z({{?}&i{2(GR`>t+!DPXy;l1dKC(@FbFSZ*X@}kXsLy}j*U$g;Jh;X};Dls4zwH-+ zyY&wm**)yOUI|wD$!m9e<(f4;r+)GZ#XevM_114F9N?Zd|IZWkklDsl&iMZPJk#F& z=jVCfgC6!oo(p+)-uPU~BlGVyUW;${7}tG1JAeKgc?;(5uR0~iB-wi`K6iarv3}Sh z%%Qq}<#V3(HOA*|*u8Q8$!jyc_?YDFc4J8k;Ra(COXk{JTQaXq`Tint|M}(VpX6-z zT<4#EsrGTNd64xy_5X7VzFrLv{%QGqj`Jjc-={nk=W-tKu&ip4S72aTsPf)Ffx_P((-KOVNLSZ@9?GkxB|*POy?6H@FCOQ!3bt;;+& z$1cBMxBuVo*V|uxe7^MDna7u%@7MpY-F5zR`u{_JKU7`GJ0_W4^0}((ZT+j2%P(2% zX_rbr@^N0JjsFkTLrd=Rvz*&vymGs+(*vImPxyWKM3f53=5Obhs$ymoxG=BbGAlPz z`lE>tSVckW($$z+A{J!)pV-LR)bJcsQL&ucbJF7R`D~VReGAg1^h<31uaBL;CupI~ zkygrX`SZzS&!5%r_g*g&nltf8{f+qgzh6W8{f|E4*#C3O25$X56J8ajiKbh9Tsdj7 zy8p7@b1aYLulXsmF}dG%+PlrpkIyi#^EVIw|G>w*PWk@r+uqxM@bb-XJGOjNft%wp z+jIA7zsF{smyELMi~Q8o&z$$nJS1kmy@h4)nK^~eO6v9;=iT4<(ENkVy}0VPuKPdD zUI`irSU1zUxc24J={q<*?^*XgKl}Bv)-V%h zuh;Et~eyz3t4pxJTNAa#oyEp1@_i;Yod)Q)e_g{_rb)V(*--Pe~^-4Ra(^xLT zesjw24f~(;q!-(MzRx}zh$nx|}^3Q+IF+5nk|KlG^ z=7W2}1^SOmUZ2x={OJS*2BwFq2j&--{S~Nx_vH6?oBDr!Ql?!GKAZdA5Sk-d`~7Zt z@I=eaGp}nuefqTI{+sDPuDJjE^W0YDXYrK3;^}PfcRrtIvt{X%GY>bPv+8~~o1eX4 zSKOno((eP0RTUl+n}6#}-GskZ=`#wKS?za7+JEj;aYEl;j&zOUXR^O}-_3t}>TRX$ z>A%Ib=VG4c-z%v7mj0)lv;Ht=T_@*DO+kyxm)FfZd+HDSrk%+w=gznsF=c{XRu>1V z&N$|DrmJ3-|H3h+bNxGo0Sh)oyX1=|H6P!e{GilnSCzwX1}gde>7Dj(U}+J=1jdOd#flb-Fr zepMBRs;<9#F8}JPP?Pv`91qOD&$Rdy$2&*xpk!5a&PLX+lUL526tK{QqoH9EOT~Hb za|=@cAFM9?bT596=HAD5fR-k(=Y&hb1uV{mRx^u8L)ujghS ze0KT3Cy85O!PlCbff4yC?!})bVJO2p%mpwl-`;GY9Z|}ox=Y9Xbxm{4x~MwVWedsX~$)|oq{2XEdvp8sp@dFc!> zyUOI#>`wOt>VLKVzL=0HcEJ6+e}UO)!EJL{IPPdGSVW&I(y@rPI>z+$-h7$w$_@{L zSvLI>oKxHDtYq;YhYkxV&SNn;*hrPNluES=7h+|nT^vTQ)Y60 z4z&JwR z=lU(bd}Hh5j~05x-_Fd*)pcIG{xA2L_|lE>AYBd(42|L39OvS6U;TvG+PIyQJkE*3AGgB9>#++Z*D5`Q|>h}p5!)pdHS!D*>6oh>6QA2mxqUj zht~f}{`O{-?6rSu!`|PFUwg}1!uq#zwFaS$XBp5Ps zq>C}T7GseEN6tS5hKFb5>yBvP@}-U%!^1O43o`gXZDlNq4#=}8%=vk^UjjVDh|QNr zSrq0(@`#*m#C{1l$B$-)7UTLKf{S5siyj)Ii2)okqlp2OFh&!@Xkq||z-VFsg#b7x zMoR`zP;8jd@V>=Za>CI$7r-^{4ZjEXs^4$j%U_Zp&v?fE+eX*Z3Jgr&gbw^k*1w;# zwXae$`pHFojs}NiEUQ+nTG6@t{XXl<%XC)i-(UXyZn^wc^_8)o(+$eL%~zeKp5eka z<$;-AGXqCMLpJA)uSZ|sV*Gu^;EcUpu|^v+lcfv8=YIRSy_F~D*BxEqpdilSV6i#U zf}5>*=LgF}5{?WkH690^t#_9fD!x9!;Qx-(@*CJ#91hqpp0R%xS-J8_y4`0akDI_Q} zp0PJObT;X-z`0Ev^0Cbf94Rgghi0BN-dy5rD|>C`?EP|E z_f6P1kKvj8>zRvO1sE8sITHSDY+rpl%9JZTLVV|0=MC&E4h|f1-dQ;B|M%^8{W6h5 zGtX)}>%=@++zz_R^exMVg2LVHL7VwM`)BP5kYaIAsAe(vpVGf~vaPd4@g%cfOpF32 zS{Pc4=gxcE&MYCWmu)3~P1gb3hKO-Wi2Qj!bJ~qRF^;dfC0}zU{M$HBGm3>#U_(2@ zGx_QryViy-F8KCyef<=lJ)a+&?>rlFgPBQT!ef?wAMe-uum}_$lV{;bxBMs1beNq{ zz`~t@dwQ|XpAXV4#`YD0$qbEm6dHc2oB!c?sAK-8C$OnOp2NZ7@?MUIKF=FvI2s)I zgby4#8h@_6;F*4Xl8M5Dbfy{gf8N|JPHrwJ>tn5BW)vteXXHrVRj)ATrOgCpMu8KF zOc6VNoc<|r?$1;I9yXRgJ`A7x=kKk~%MmztZsUP`1qP;fY77s3zVo*j&t)$%6XalP zTk&*HhWXNo3-!D{FPO944EE^U^N@k&0Zu=Majfu+t1 zZM+sQtnp%3Iy&ds)Zkn4D}%0kYxcTX$ns1NuoL45?vmf>$-pgM|5NtR%>92lFE%vH z=S=wbadY|kUfx4DEyaHDvN#;L%c3yn<-f*)WuG0+sWAzh%VFwH(^YL~?OtlJ`JP0} z<~6$+*cuo(W~em$Jg%bAFWy}6tdRXsjlc;-CV_J$>CP6HD;1`zH6~0@KEPzyr=u;C zv3QzR)wiIiTerP3RhB6oZ`&E;G4=V5jhkIVR3t^WeQNLW2@YdxYnF*LlX+w!^~7WA zxkmq9vLKy*4eu;#Hk9p3Xj;^6_#Re_hvTP|(7w|I&e@27jI>Glb$Ts=dt zZRK>Ia`Im0-pW1fw`MLfOeri+6kb~UvvdkyJC}y6q}tODhgY*~DEPSQ`J>ys4|VLs zV^|pl3P8okyU(4nVvZJ@o3(C%k}Rn0I#&xE@tzzG7R?3Io+v+hc{^f9#jV=slKcyBi$;BBi)Ms2@lJ;y-g|j%0)hqmrOa*t@`JjWo15W z^6gNgnb&NqxP@CPKJXYH+&J-!;!Xe3DyRGw|Hn2##fn!a1Y0CG25b^5u#pVlJ0mv5-NeXqIXk3+oO(u<7u78^-DegE->!1c+$4l1~3 zu^*n}reu@v_NlV}!kaW7iVvTa5+J`0RI__iXR^DsCwadG3NI&Sg2y>zx?5#T6g+i-5Y$GhS&VG5*^p>X@@&d&8_3 zFLf4f`5hv;G*(^vXO(!oeAvWPXXih)HbR1Hl&&Nh=1tey`_|oH#WUYdhv1`!66fVR zYYDAgG3EKqPg!ER?U-S5*|3!c@<-%M0&F}7Z@$agoWcACAx`c^wATn=yvFf={^ z6*WDeY*aJnv2DaF|MiOvua&-AWFqod{gIvA4()a`kw+bE+cvFCx&N_i=_$WCyJq^f zCqA6y`|H|w-iK$ZmgVIXR?OJ%pPIkvU2x5+j~|=&Y=1KITb5CELBQM4Wv9}sbrw#z z^;xI?yPNjztSJpZj$qVy|no7+>GX z!q&*Zp`yU>(1)G*q0jS&k7Zhn%`L?b&78fb@SK=V%(;yp4(AJ;`}5%FljMeiX>$a< zcFbpZCjb5S2`+BMIgyTnxeW{)7N8*wKgmNg&mAt`c|zd*T%%`CR-0_|D$QEdVV#Jv8&SEz55Q1{P4`L-C+yi}Bp{{Gx{v^Us++JQ8fO?9#u~aHw#S`ee>X66>K}% zyhA7tRMGavZM>((k$%nCwHws>6lf?|R@_>!?DGd-zC$yQ$8bJ8Bg%L9&{5I64QDtw z(x)vvljSUMF6oH;p~x`%wr%BoKaZCkS@(kB&`j++49X4+EIuF;oSQ%<)U4aEed;lT zZI4C0y4!z*=*>CwRPeeM<68CgO+0oX#fD3nD{HjoxVt@!G~QV7an0*TQhX0}^u+jM zo^ae*sCaMjn-}*AQ$An3dv?mV?<+lG_Pfnc{XC(|;k5pP6Q4F-{ub1G?4-K}Z+qdd zG^3WhJ^eqA@g8E(PmH~DRFxvf{`jw#}DUhN+r>`3?&&BCT6 z6S;BuM>i%T5xr$1(N_i8PS+g+CM_xCN%u_^SH#5mp+r>*?b-A0}J0N&aq^Xy4%3O zkpdd5>k|Ma{boy!bh(mQnUfXg%ydijbkvE_Irw6ho5Gx($3&O$G!~?BU9N0Zm@~Ki z$YgPY{}q>VHKlnU>ZGQuU|>?1pbGNT2au=copxnAKjEx!xt(8TV4eKZgG~3Nj}Scs`b?y| z+#ZD=xEjdWqFnRq*^{3#m0|bKEH6;{loOH@xxe$L`fI&{BMcnreF<*r3JgqQ;PlY~ zN*^(Nhh{#HnKtd9z`09@rWL8`#00rLd&MPiF6Qk#!N&@7dKYYXBF>RMEumzO;2HZq z7J@+5+e9yxsfop@C$@gR?8AYx_y&Xt=Y;Wxid?WxRY!U{v%>_mzc>^EZT+`n>AAYb%|0G2wQUl&0&FnYR1H8P}%yXqOt4ehl+c zuKZKpV7qtG)ez2vf1Xokn4fmG_`K|Xf~oL}caOfFxo-DUc-MuS2VbAi&X4x{DSBOZ z!ImrO@43!u6gX!hv+jJt!$(!(OH&kDHcu(A z43juCGkL{IWsdZ7pZvC)pRs>A>2sJZNBX@_;%lyQI)JKqTlR-O-wi-zX?L;VrnSG0 zuz5-I(to_%dD|I)jAWdx_rKmIju=cZZf_P*5t3>Fn;4mN}u$ zylvm6376JQ{^KGwG40$BSC%c8x`MAfT+?k9G%McVK@a1L)DWf_^=?NW&z>!CuIBNn zIWirA7S?q$uA4J0&dU+~yX()Z$|-TR>iZ>wLymmjKgIbKFDNtHJ6c?>++o4~@XXP3 zjRnuro-av|hQ=gE`nw{bmd$T|F&~O7`>Fm~_Ori{)4v`356{Tgow*w5U~yXaytBpS zzr3ItdDom`eO5-R;uA}~zv88wlDx9JqKs=dt?S)2V_(mIy%VS1pUjLsb9PJj z&*MC*@258wJgb#|_K9N>XS$nNYN%?-%gy(HF)U41yCT)C@E$a0Qt}*>Xm;qSJBfiB zCT%=T1<(BUFUb}a(|b9oI33hj{=nU0T>isxO+$g2%AKi6{|cwuzuClgXl886#dV-g zprzwY+gb;U%e9Q)fQ!)(Q=P5$re?~eN5*^1JrxeD&ui1Nc%%_wP_^XJ0;{_pm-&B^ z%iPWXX>w|^$`rwdoSmkrPK!M&=4es8Jl<$47x#1@&RwhRkW<*9NF%3n_m2O0tn)tSE89bz=qDG~i-KlY94}hu zf`W_{6l9BJ=9gJM`%s(`{B&dOlzF8R%Mo#n99phX7N9_-v%-f}mwg2kh zEh)Ku&(4eeYUa$7N@rW#Tv%i7RMzsw?s^}5#xS?;)c^KX(}Jp(d!?^f_oG z)iuI9g#Y?l)rOzPqvqx1@*bKQZOgp-jDSbD#qPJ0uLpd-erMw$A9IbzLZBS#4o+|y z><`blp5t(^D4uNoW-}|d`0pEOi&Ht$Z!VCt_+88SP{;bo<=3KzBCGDKS=ajWc-EqK zIsAuaTK{EQeMYdMAgyn5yqBZJWm`^A_|1w*d|$XJ^-F!NJ+a7PrwkYu>xi zO89t%HIuK|U;7J(1k?6ct!X{>HNO6-$KG>!VrG-y3aT?4KIQxMs*GaA=Tlj`z8>5! z?Vah&`(80U=7o3d=9dmFw{M2m%Z5sJgv?mmRg}(|@bJ<4{>G30FV8yH{@2uDdas(; z1CHz8l=rYdo5x!dwLymOfl+Z{U!y~vqkZhmZ7Ie}r}o<1XT3UaRokNzQJ|r#qYkmR zWLk`;Td}3hRAP8|MnvttNK?VG-9n&h)zp$B{bng^%jP$q&WF8^*pbq`$M&z2#pd?x zMPZ6_RFxk0z7{yQ>9zV9`7-~UYL*mKYB-s`77x5{*Wm%o44-))}F z+3W2h{VCJhMfwlb{CoN-H8tevyng>>AxdX1-Mg&Q$+%Y3c;%6gT=zcfEKqWs6mt6K z?T8&eUMn*^JQG>}t+aT_$8F)_LC5bd|5?p{-~QY$#kcpG_K0VBx~iKRW*>F);_kNm zWc9Wz@S+Ig8T(}hmHPx*jL+{jSkBJyP-puc{)ax_%|W#ychQX z*|V_wQ_-(wFKzcui4@n@Tl(w!f93g7H$&3T#>GkUl`q-Fk+wEhrti<0Fng z_G_qLvhR;l^}dstGquG&?65Cmf1kLmCQnuL?CH(N;xFYdGLbsBIJ#PD-ExuYxvEM_ z_e|CAq?(rTUY z!6NnYz4M?!+@HIamU5)$ePns#(azAaxnxDlS52?H>|&ckOz;WhPcDo-N-J?c!=KaN_jy;2r7lTc(}ST>WhC z`8x@-<#P}GdF;M^p1m4J`g7xhqVGWq8)V}n6G44fCD1^USh2vlO^ot~BF$Fp-tMmx zbLaK!5}oFPX?$C+ymqkgo;y=K%fX`fn)i(QIZx-Vy{9lIwcF;`)+UA)V{MJ9<-8o} zyViq7D>h9#{r-8djC$_#j?Et!-tR1(=O|PdwtU`;)6b%wi*9*#sNvS{oWEiBe!qGA z{q2&r^Ch!+P6nl2x&C2Z&UyPCE$g1FzO1%(qtV~6l$!9m3|Z4lMM|@Rl9#%^nIK&)`^Z5r&;yqODnf*F4npC3)F8> z&IT1_j{FaGtnb{tr7fnH_tmGW%+bQTfA-BNfpcdjHWj4x1+SZa=+9*7`3xNCJ0GU+ z?-M#SGu~FZK1SeNi5@tup1vwq-TP~u^`6#CoBKMmy$$;3KWSfO(eygU!}U(l%Q%^^ z+0*B~d9wG+yvnU}XZ?!bZ|C%KV%R>rluw?O5xJ?~-j#<0|JDC{*u?AZw&OQz@89+< z`}?g>E`QbI>{ZV>4(z^ndilxNy^E&ZTD|JQ>xC1x)-5{b`}aXze~9jqOR}lOzE@9O zbpp*kd=@zO=4*Pjw`}Q#;0+7uZ~EEC6E@qlK6kM`|DL_S$V%dA$Tz;NtLiT=m?XmEZXr~CjrH})lvHN- z6Y4A5Zfgo1_%m6_pkJ)L;921DgE>l|Le7pu;9N;PXlfuu7nCbsr1l$gq~Cn_ep}qe zg2LJEMw{6WMW+3kbhWL(%u10Xeevgmv2z)o$W}p=17(_ET4uX-c}yIjK3z+4;|r-(Szxta=h& zw_*O9o5d5pM|me*<1jaLkMrGG5h!{0_c43_F(M8*u?!(?Z4N1 z+HaqhJ~u%%aO+g>CWZr_*G%nb(tOPQ;-2mNjAdV%DyL3qJ1q0?!IvMY>o?E!-9Aya zvBBEUH1yaGwS$L_ny%Y$MBwp%<2kD`Ij=SBS+w)x%kC8i@2wNcmO8gVbY}gh(3I;} zI{(<3@16Pl^f9Y{|E6+0Xx9D1TK|BfJs|();gyDuy0cZy`_eBKKXtK~Z9DVr43l|p zCG6Wi8?E0m??GL;Ttao(+vhd*{@#q5w~N3PMz|3&yECD_Y>_-SxqH3WcHV} z|2)2a-qquvhFqdqdByuetzGG#9lfT<9?cIBnf@tmU)t@$U3X6U-BbSL6E0qAyD)Rj zWwj+&MXk+_X9cQsS*5da5y{*Th1E%=DoYDD*Eg(uDK$GF2&wl|>OUYthbHOu< zqP5N(=~8`jK)u2B{R}O})qgl2>ez*Iq<`G`V3j4)jQTT$pQM z=YOj}ok4X_wbyLZ<(ch?ic%wK9y%ZB?|t((oKq&Ty4K^+V<{lQ7ZG&%E~`2wM&}3>m@^$ci4T( z(T@Ca`M&XV+259*&YAF=?rW;)3$1wW)n`%tzV}S(K_0uimv(H}&f8sd;oh#(E4$|{ z`Z#5lY=3Uq&NYcTF)`03@SHXL_uuJAWSc|i*URdwUTj~x{)6VpoA++7nINyjp636w zvhbjf-)XNOzrUxbpHGi2`#AaYTdhrN;@|3qg&uplO50Lb|LK-3GlDbYmAVdRUEZR& zRdc3G%6DFa1?%MAz4sAc{&u;g?b0_V!mFpA3z(QuyzJATh}eTKXXfecFO=sT52IGa1Rk%~Uvf_<>xgp0Pjxxb|FO*lX1n}TYl{{=RS~Uusy|yJ z_OH9$)$QA7|Ch^`IA5)P|I0${7yr&2ovfj|T4&jnm-0#9U+VQ4+%8QJpSdCVKc5#n z+mGHwmzd_+Pd^*)b6vgfF~^nwgWa!}^gYh`zU%59y}z&Z<5OB}c-hwNd+c<|KiXj8 zrTKMF`=@M^H#X^1H8CV~_kr7s}Yc6=klKTED z<3peClAz?2yfdNUyFuoi?>t{4Z`Mt?v_vpnbWf%C27|5Y!bXBy=Rfid{&&3mhvy0I z?4NUgGm2)aF7KYn8W?h*tmZ|SUF_S-?;ooRRtoOZtFD@vdtUcx%K7aFHGaR`z2C-R z+21$)e^M52pTBk%vIi+a(=_FsDE;EjQURZ=06HiYF-+vS6Y_wYWl<)p_n_b zZ#umaKk1$F{(wTf<%(}1x2tD7pLoq|pIxQR?adix{mYkhROi_reHs^a{;_4rl+Q(6 z=|Y=zcFXbQ=%2LGQNTh4GOvc{gD_v825!TTTEip2lquHU~! z?on_}s%-Ka*|68WCpRCzytaH#|Nnh+H`jD+T%lXpS#xVgGV|8^m&!UWt&@w3=UsW? z*$S`U)fS(0M4o)TbHlApIQ3>t--C6lpLtkC_tZu&7Gm7~OtbGo)wAz18%y6LNKcgt zdOd%lR+iG5RgcZnto+N0Ew{4Y^4Q6;KSH!nt$K~^$8PZz%S%^p532f9k^X1i)Vrp< zQ%mZPmVVZpdfszh%cSG$=Y9@Xobys1G^59Vyhz}jjPu+uchGR$8i*E_P zd*1(HrEY!o%=rZ;{-y{rygKOa;3vEH-6Srh4{Ph^x~{w{SY^xl=SXV&U+We7A1b)- z+Ife!Z)SUIck&9^mnVMMwsdShv*G(!kwE66S!pE(*7xNv&$2XneM;0u{@KdPmle|P zE^F@|n#e62>T7C;B#YYZp+Z&@-%5Ah-@Z{qYUtPoZ`|yg2d`-KikR{A`#(w8V$A*Gqb<;YQ@69i2 zxwd})SBtw7I5zv(^PXNm%h~tNkA$kaiMU=(XxsS{PuDyL=yXyDi zKeiT2?md`T+GzMlXVp9YZy#+`>zB7E+sxOUy}v_ewq56a4^6En!Op3FCoH?!m@(z| z#@B=n`UD9cQJ5p?`7})Kvwz+X=|hsgB@aoq7#sh8 ztT<=p!jyPPj&!SNP`Wu@G|@5a)bjlsyM9mEzhH92guEj&bE@hOR$mL?U%vZ?=hioS zliq~Momk=Q$@KX6hmEQKKTYHeDS29CH`PUHNe_2Oa&_Iw?g?swHTt<0p3c8t@$(1l z`p@Amw2IxjF{MWPw8f+cSL5{;|0H``FxU-cQ^2soS1zz02a^clt_Z z>b%J(7Nv%tTX;-wf2!uvt6yabQ?|8VoDGFzv7$Mv?|5UjvP; zvz#rw-A>&9ZutMl?fphp9O(k*?)}nnu$cT`u!`%U&-00IWm=5yGK0zwz9$v@n_rpP z=`Jh0|7*%GAM-~#tNh$|zMOpL{kvuvNmc(Bs)u~;EnL6qfk%`7os-M=-l?#hKkH`1 z>G!vmr1|FhKRR-F!$Z67c5)mczf|LdNUm-4F; zh20b1uHW6V>xI@lmz6hC%lGHy%X5owuhuaAtuSZlG405gZ`bQCPz3`^&)RIW_-fzN%DiKre7}{=P+pt-?Z?{Or;2iL#=pd$w=67%8BmE@D&)0U(nmhp>z|AAPH9s~X)5~m_l1U}f3N(?oj#?9Cdz#K`l5H*#)6M+^Up+a zK0Kqd^5SV;-jCm}&N{BYyy>FjJ>iruQP=KH-Fx<4vGVumz+)5hc3R1vw7GoqDF36n z&ogelUNismwTbe3m+q;#r=0%s>-oTg1s2wVDvP6E&fPtue$V68K00SZAHI>#|0R|9 z`gK`Z{#uuPQ57$Jc75&C-)o&WF*!H4z-tn^ZqbIhPYSr7{`~E=vW!B+}d$)h*x)wbO=Sbf*TXD`yo1F8G7Mo=Y z!>k$#ris3pR^HXx{nTUgtp$G`D!V208!JwX;sQ+xmrb9>D%N7W+td2C-81?0%JkLd zknH)US4N8AVb4EpCyUe9%rCw8>tu2HC#WBDV;=Xh3jawjXV*FFjV{bSSTV5Dc0%l>3CU(5Fc3ih5>*N)X|aJ;`H^z3d( z_=)+Khw0CMPT$*Jxvwy*qiEN&J*VRr>z!U@ zvFX;ze}Av>Ka<~IY4We4DAeM2`|EvP4wvrU*4aN{|Bu(l!-B1KfASEch#f_)p_ zy?gTYmg?z>_iIzRf0kq~zWaLqeZT2-e^mRXc$fDZY*b!xuf=n_>_+{_BR9MIa~CY) zuYG(zNOkR&P1|~2ozz_WckfKjt^dFM?GLIJPX93Pex~Hphh75bN}hvezi!NHE=aRZ z3NK-Jct$kO<0c1mD*03UdcW9fQpW}U%*i^G<;_c~a3zn_{1 zip4X3?za}CwN3hIByg@I43ZPMe)$E+Pu{*^k=&HK^}gR^w4R?>kRYhk;-loe(SoI| zS?1uz#V^x#N%MXdo@L=${7Ohn?_ZI5i*fd!o4fXRO5NI9sJC=uV){3iDSK1$_Rdn~ zJ!V$0zv%9)iT?dNJ*y|NE zF{?RP7^}|_o%%uU^ycZlitF$EnK*UIHPerA$+P3{`u?>4FMlsNQ0c;xkkcvaeB=s` z-A~_N+oODri|eQF#E-&1OT2GRj!?Sy$ThrMkx%xBhTm`PVDIXU?ULD(06Duk|n@aCF)f@A+b2i1iNADXEgA1QFI$*$Cv2>G6t_z0+>?tgUTYT=tP}27*|FJ{H(apm-k)PJ?oN-lS3mr8i2Kj~ zf0w*}c1kQTX578Q!D90>;cf4GC9edWzSlX$O0+O+rQi9Q_r5ZPVH4K>3)+?zu`hFJ zeZ&90371X%%a(mBcip9+@@P%wr@E}9OMm9RG;6I&2dyD|emo)It?QiW$L~AOjGDRn z(9PxdKhAH-+b}=g(&tFvv$y!W9r-R5jv)v@`w#I4&S>*YU7NhL9_+w(-| z)%{f+n+xRDtJ{~oe|zO!&(X6}#8z>boSO4juy<49?6^nof5!jc-+yuKy32cmQm$Ni z)v>wL*h1(Z^Z!qNueR@5H|5e?>+iu@^QU{BeXOzny>H)?-#@R*Ev-@7o$9|m(pxQf zXMg=n|5#m(4bT4Do4#5oU&fuT?)PDKMQLeiX5i09;`=?mzT5rYPIvG3d)1e-^dnV4 z6N{+@t^Yn}2~8uxfz%;BR-X+Gf6@|JdJbO<&7L zI`OV^LX{q!DNFyklUa0S#e#22QmN^J_K_byPdM9i_EqcY$^LepcR^?QE%^dEAW=jx zeV4h5#qR(Ae!o1HZvQ*dYyIADQF{}*EGvt2uNGJ|Oz_aX`{|R?s)AKp=Us|8GxNmy zo52&RX;Do#%G$nJQ=Xm&Ej1?Ed|Wz2mlne7%35+PVHcHUAd< zzvMiB(eBXFnpMSf7TsFvUiV4o>HLZpu5YUrow*}=`NcK+KhLWliCLD+Q9f7q{fPVz zyZXCx|IU5Bzw_*#>rpC)UT-|}-6Hv+&MLo~JSzmuZ~pe_*<$r*&6YhKv;G}ZoU%JGt=3=egbYqo{Afy!xjs{?-? zb3B`3-B>WqXwwc+g*h8PP1AYAb4YT3k@R=xFWb5W&fQ}>d@NpZ&dUP9LQuI8etB2l zuMCSB&n8EHU0@%x{NC1Ma_QTCpP4WFf&FE`S0Sl;D=$UX zMSl46`FyaPV7k<_29EUUv1K#M9`Aa+ZgI^n!Hf=z%LU8kM*cT(U-qqJ)#dWsK8wkj zZ)2~2urc2@QTe>nIhI{Z&uT`CKE1LvtaR72_x}zF%4+5i?>G&e0Hud{0LAEYqH+d(S=D#gV*n{;HMkb9h%ip4k3WL_+kX+VU*Dx%T$&{!jjT zK7aXZ`+t|-Ys&f+o!JzxbZ%bVH&dy6_urYlr;l_dYuH!+3h`2Xv}W_-?oZ;e`zKsF zo%if(z~h#vQ;*q&PlQ+)yUp-ia`V6ehSGxHZ?^|`br^r`JbcKtF#xm(n7!yGXcS$p zQYYq7;Ku(_Eylkk(=Rc%Z1#C^GhY1A%)dQzUod{|7oHOQp7~JZt22+T7DOMBY%%^O z1Rm7*xBrwWN4ir}unV{ubyenR`37me&vEIRJEB&JY}G%1N11*RO{K|N67|&$H=! zUPT5TUS8Ptgr7mHRo+P<^r22<%gim>no8$dTD&$ETx&1eUVz=5>t)EwBCNx%kni!b9V$N!ozR*vTX9z#7$qHFlG@(#EddCu}M76hG zT^+`X3zw(08t)BLn4rVN%b(YC5f9h-lO`>J2RTWUXlTGp4x56wgizv;No;Vo9kPk5kGpTF8!pZRGh zYsl6@oA;GzrAKDIDHP-RXj61`>GXhl@8?JBgH^8Do^yJ2cIo#!wYFP#f7mp0%Cztz z71`8t|6aU&w>&=nzw~~?uP^-PEv@kvkDp%D8MNB}s?O>sd!Oe0IFNc}=?~)*jjtMO zL|4T{9eOHsv-S02VGp%AOQjOi-tY>hvnfUFc;Nu*2TnMx$H684&(}^D{XPGc+Bo1aBj`dIltG6a-{Ea zheqORrE{;0do8q|tN%Z^=%O-1(x>TrT$PQv7K)j^{+YJt)Xxak_ksnBn*1k!+@owH z(@^+OX?fBqO@m4P+h_CF|2P~{dZ>l-rP;&NRqXt7OA7yfS#EzeqvjZg$w}|U*;ew? z;wLV1cyQGI!^>rirK`fZLW8wdZ7bkh6LI46txc7GQtU6Ubzh_<^m?nW4CA)6&ATQX z*!A(1$a(4Y5B_mqotMqo)gSZj_4AH~<6*`2?R~v+AGg-n^%wuF$yPd_qQCWP^v?g& zuYI=A{b!tzvgea`z}$6}AJ_iUJX9pV(h@vxQ?%=fL=T4=>?{EOx)02+KE1fOII}dj z$HBVhhr!m*ci-1dzjVI-@8T))n;k-Lic2=KJ}=BR3Ct z_KC>7dKj|f^sW^vXDV;u>i)6sO`w^`)5?c2^PaQrv@<#Jot^#XZ>v?Gg5vy+%5+-n zK69(XSkhgk|M}u&o;6u-c;$D5kIUjKB2>)v3b4yMyu*%!L za%Z1j?B92yQ`YV1Q~q?sK1ePzi>2q#&A;F8`}5AREN%;mls~CCbLCb!jx+PlzP@1n zZin*KRZ%~GzuVpaY0|yw_j_;Et~L@lw?-**lPpKNnq%b}i5BC2OP$7@5j#{El0T<% zq?<9H+fd1ouJp2(Pw0?j%jS#rii__b{bOu3Kj&_1K^ku*N4n00ldIQ%AiNcUt&t&l!6lMlQ$_}iS&Ly-Zt7dfVWo}9J#;o(;`zm$*v+_HM#zl-y0 zq+d&aZf*T@siM+yz8w?me9q2ae&xQBqv*SL~BLehh-?FDow?KJ)Rh^AHT`BbKZ)$W!D7@r)fH_>xV3sU%H~>`=M8F->L@N|31*JS^GZLeD~ciJ#UP4 z=ZJpT^~`(!50S6oq2YO}?z`9h(wSQQNL+uG*8Df2XO(5$SIiB46>@f8*uQyvDnFcb z1D0p?1x`%gWGB$*qjchu=sBh=r9IC+b)Q(LD)zQ8jcXcf>zghM@043}xR+g8TVD5~ z$m_U&_7anvuZ7n-c|M%{`rH1mt<=fG{B|pU6sA0y@G+ujdXKnZx?W*RXqtn?X3)-* ziJgLnW?KK5xKcD?M~c^lyI&ROEPXBy>Rh`0e&S#u-P`xGdPe=3&7X=qInq7juC^4o z$>$5+dJ^i(k-qZp`u2ipQtMroD$aSCA^>jjq+fl=aFD}(7JumK4&%ehKN}tE9@QMT z&s}6Fo9q2HwnVvOYIuy}^nHJx>IX~pt*Gl|E!L3_J-(&(A$RRBIm?s&Q?xSjdL z%E|KkC;Mv#wf$S8n+EJl7!Ri+0@AEypC&l+UT1lW>;3_xJ1f;HaQu`3WkUlinIfuQ+jj z|37sv_Fr!QA9;%vUb}g^+T!nr>HLA!fqVi!8Et!vB5t4fCN}-kp2W~2ksi6r?C%?|S?qJ`Qjl$J2#>pJ!pB%WN3yfRg#Y20#eLohW-Xgp z5^Sm@xu?Ie;5!sqwqy2NQE;xc(-t`AwmA8p+%x&lYv$;1K0G6JOW@p(iN3qmXE|AT zzc1!^sAHQK#0_3*Aee5&f4kV!{d#~rZ~OfdYxKXau!>oFZ|C{R>DTY%#M|35?)&$6 z&&@}T(=N{6qhkE*m~{RUrTf3-_P>^KJRU>pm*gFrprgyrl}ssYiDvWtxoB14L#6ePPCb}cGwJ?Ee{-g{ptE$B>Fxaz zlqvKwl1YEh2d2UuZ#JC{dZf5K4RnD2(yHrm)oa7z^6VU-+kd-)C8`;-?{J zcdxH+IF{YR(*0!hiF>EhF8;NCU&p>wHFb};Olr`Ni<|zlOs)IS=&oVAW%txe6T{^~ zqd)7<^V@&->s0HjQ-65+%eT*3TQ$dgZNBX-N7nYI-PZ!vE|~DV``E{v{}%-6|DRet zJ+0MvrFfX|`K8*^0{y+8f4=|v*Q)n(1V5F(-gWi%i%0qUKC>*nJJ+T!V$)gk^QKd; z<$o#Hu6&h7jAHvjU4_xpb5dHK(I@IQZ(-lQvUG-q$WUuV5_ zbJkTYe}&e9XB%cK%(3LT^X#3%oSQs19zNSxP$;qE6%pw&sN15sfbHR#S^q0%ZUqfQ zvp@8Co>;wZS*h&Tiv7e=X)E(V!v*h(O|;MG^74f!!w7e zDTg9AaTqV|+$mM4wm!>k@t<0g|A#VLo_&h`@&3ZhZgt7P)G6;nt5eoizVgj_{#4x} z>BE6J&l7yJ&IE$ETx`1Jkdw7b?XE=fmQ*p~Gu=k-jC2`!bm84`5t{`)lLb4-kV z2WFcWi+$akBDbFXl(%J*_Z!36W%FJwTcrOd({`!;znPaOAGbbhmbfa$mG^(`hDTQ0 zzAuvgadu|g#;eO-NppApkNbOCZI^19{LSh+5hjcOtd)O!VsrNW!}ZT4{^y-pQ79kc zr#45jeMT0jC&RVth&|GFU+W4wQN^`VV_MT$k9DVEarPyc$*$NbHhsRjzs5@ z>Gyvb*<0G(jPmC_qSI$FdGDEN=bx*;6+iQw->Kj)L? zW8V#`{WsIjYm0{*iVRhLuW0hyDO>5~`PUlmdZ6-WaqpqXRf1{90-t6bxp{uysrC0_ zYn0EcpWS>+Xx5*azrB*m>wP|NI`zu(aQ;1yCqX&~%I|%=*M0fbqWp&M6Ygx7d;d-4 z$EBuM&I$M%GyeR&II(8qwYo*N=Z=|~2JW0H{y}bs>d`-JUK$x^tsK6kybh|KYy0VO z`pW&`Aui#&syj}`6>hJ6l$EvK&viy?iqloM`BiS;awcDPjIZfYW|y!2P?@E3^|HD5 zshO{>|NrA!wg1tspHsxdKE^GcavglQPa6c+dK`maU$nv`KIfXeV)ep*R zwiws{2y|;~?VeCz)>`mP=Y2?Zb3t041SnNJv(xe zny=20zVhRyT_!EYb2XICmB?*-e#-Cd61!t>>n2|6Q9gGi%y`rB$PEqieRx)%^{LzO zO5y8g`}#|hF5VC*6qCRIK*3$?PU-d75QWRZrvLvYKVC7n|IV?MffarEuCKzAUvXZW z*S2~^QGQ+WtKJ_Q+?H{RD+?vF+_L?C$Jw=D)iOV?md(H4@1LKiVx=0qeyUCSpZ}9z z^XE+x?v78oFJ1F1;`F^VEdl9m&p3B{Q4>rLU;E%h^UYw(PiGdfU(;HrAkeYOqv7uF zO8yJE)s{**2B#*nPd@f}$1~r@|5DEzZZ7j!GX2fUZI$BRH@_G7K3V)BXZ1Cacj>QO z-nsLw47SXp)|G5v%tLN3f4K=F$evf&In8+vdYEM>o zlap~7Q&U4r>Q5cfoN{iq)$E|h_jOA?=Gs?PSr)C^y0v*7-^vRwcUk4F-!tdClegJ? z50+cow*Fo*cmLmKqF&!~Y+YU^?4Mk4afk8OM6#5|KL-OcS7 z$CkUh%Yy~ytXk&}Fa8>_L!#4Sa(lrup64aijRk3a zb2!2T&Yk(_qzPGE8C@iB?#?65^4p9Lb!LX7_x@Ly6M6Vzj<&$LlAsQY%au3gX}@@U zIb1FOr}0uJ59JlKM7Y$o|r%H>0bALFSDy(2JN~VTlsWq$p7Q^{|uM1vercP ze3`sDv%cP*_wQo)zZy%=+kEzk(olKyDEhzA%vblfY*JiXb|y?JOfcPgR^BqLiw5=+ z4WsX`f4U}ahlFLPLraJ8+@9E&4-ed)&&gX?YX5%krs~HrS%+SAY%b`~-Tl++UT}_+ zaJ+%^%c-w+JSjQZ=-6j6dBT=GHvbOCzwz7}n_SuXL263fm0e2lUz4w{TzIU`^+2}e zIl;vGAJXn_(%(yruX&s53#JE)e_UD0D{S1Ibo@i$tlaj_qt`e>aNe$+nZ_l^tk`y(ohevn44yIl>bKS z&XH`_70f6tEj<>!?u@fV@eN&pb24ms#pMF$a@gJ!e2&;*!4p#oT48(V>^H85XLL08 z%79OmSS)-1)=izJ`{~{r@k28=*EJWU>F0B#Tg8KxKHM%oD6!wzuJ-Zz73&M8%x8(d zTDT+1RxsFqOZ?4>Nwfa#|KI#w?&ZtJ>K9giGfzJ!v+7g3{h!85mxAir?XH&n3EQ{2 zd;X6*Rj=2YY*`zvPW7_LzR*v?N%f{-bSDt$ITyH^1#f@9T@ zetYMeDIHp#sz$z-T)yt9ian;evM2bG%VVpXCk0FQ*OWALtziG$*|ogJR^*ZT-_Lr_ z)&Hc1&YXL1?j}aN)eS$7r}Oz5$hgc=WoSO_;t=O*Q~HbN>HUi4>-o94%QfO-&wl>6 z=;$8xsC``~x3^@n-vtS8Sux>0Pt=A2wWrzH+M2y5TD*52u|LA+DY$a#jH&bA-_V^? z*(=*(+-}SE@J!+ZvvT%_XVMPvnfJGLA3l)#j_=S+(+0&kk%tZMhPfC_4q-9&6yD1H zP)AU*JPwrP_DAH3wivJO{I%LvaFxQGpQqQy$?~=s-vzBdliT*Z$*|7JI`g>v%Jqkg z|D4+$D%=02H0+=tqq?=_qPNR-n+1xy`xhLEUiDX0w6Nt;GryfjsQ#`ON?yI(mqTuU z&8nPvBd)U5f8oyq>~;>$FW-hP-?_wM!?CD)vmbnXufJs0)Th_pES-3w=i4RDlGRIp z1%;-3;plgH`{S*ZnBKonD?a|_6ik2lvpl2Y`Q)wsALP`VnSZ`Cow6Xzg>^&ihp@cu zYg}^KrY+dR;gPFu*%alsUngk!bkAzeRn^Znz45$u`S`@-q8ARiqV@6W!snL!=?}jt zTsUp|`L}v||5+wU@qNB;K9BK@k+b3d9cL8HW->oKqqadVf7@pt^InH<2HBQHb3VBq z-NQcXO{7^G&wJL?JG*_ZsUG()3D|2}%@ z$evia&;8r~reCY#NKf;88fM9nzVm7R`W~@EGv8@~G6#FnpC>zh9jSV?y70eS_V1A6 zC9R=HZZ4>r@?qur5BH+|_a1tGX8Em^Iu7do^L#=r8Ek2C{>{U>zpLiT|2=oBV{^iUx2t8V zrz|)V@vrS~LD2&CGxnAbo@{n*DVQc*=V#^>ssG0Qf|ILx-W|V{Ic4_c??O(UpK;MA zQ)9-xT??K{%zw8t@2yzY^7Z}Cn@>(Y9$0s~aGQf}{>(8+gbW^TvkTLxl!kw^0@p9`E@;|f}hGw;|ng*lR) zPhgAfBJ+M&-qc97Wqhb3sd?wT8ff&k78EJq#daT-?>{&(x-qeCQ_fVzgEe1MoLuM0 z+8nFSkNx|7&-I%Iugf*d#l!mFg|Cb8EZzTo@B2Wl>IFZ)UYVNsRQ~4SH|uu4TU2QO z`_1Mld?&lwi=Nf5U^sZdnE%tgw8zpF`pl1;?$>lLo-V`nF)j|&cia9+p^!rBplIW=Uz6*M7f-6no%yc)$`<+FPc1bYTJ>{oocpF9@Xdd2 zg5D*cWu=yDeqZ&KnY2Aodb|4HWqBLpoc%7(*)~P_?5ks2U$IW`?q+y4f6Io-bwVx1 z=Xah7YTjL4VY@BNwV|}+L)`Vp$3OqEs;x5q8Xgyxl)2w_Z|#+Pd0W5VT-YpQsPlT) zQSY?Ne^+%GdG&AmbT8v>Y)*b5=X!s&KKbO8u^DF&2SF9+us=Lwcx74%%fmB5J?rvW zxW%vU)=*_uoU^n=J2R0Z-BWO;mI9Mli*fLdqGbZl=HDqW1#QiM-Zew}&$Hn8@z@nas}K>da7r+#>rhS+A-e^0l3^yci`JN3IJ zrVB-yRh!u_thoRCo;#a<Z)`ukC;Fa zZt?IP2aS{!=0vtll6v*};pM63cC~^}7dKz4tM^E``6Z2yGkRx}atimS3a81xJKa=2 zM;_PgyHoY9v~P>m<+Go&a!t1z7Yl8-_j}ta78HNt(_WiP{j=Odg*bZtByDi;y{)`? z`;$S9X1dS7vK6#Q>Hv8;K82Y8V-JL4fxj<~47FBkN>YioYcMD6uaj+vR7 z`x%A&zO~L@kvRGKQ_+jYv3uUd?5*Oxx+Zvej$YJ}YTfx(S3R%azU3FF_07~j$LOYT z*H6SLK7F8>kT$EeeF}4ywx0bwbwk0&J=|Lv9V{*vq|9k7n8vVB=tPJRhezB}fpbCy zhf{lz4_XOkZZ1gU66V_30y-5)5VYi@?`acbosdo1{moa>U4Jurowqs?B;BNSNhj=} z{|5P*F9uuxALzH}{lxsf=l7KIC(45F+&>*_%|0e;d8NT-;>5kD7X{We>HqXO+fks9 z@Zi4PvE{dFK8X3Lep0;r?fm-l3x#r1gM zzQ1<;hj~5f{U2AJ+Bn1ZzVcTsr#D`rm0{s$|DIs}Iq}2>mcv2uw;P*vZ7%NJHuLhf z$u{TPrrs1?`sZ&%;IYie&UyMz4%;#R)H%8|aWIMa*y*7pA#Q}6GoR9|Yozc}^IuZxQR z?Bcig-7nD8ecZ9-rm=eP&*Zglr@Xtfv;R`?@^b-N%ZtQTB`=ef%6hSD&bi32Q`O5) z>U{i>eE#xZuC3c|s!rEgR1 z=V|9WG&A-8(iMtl?5}+)$<7uycjuL4`kSVLXFTa&l6XPIusg^o_Tn{P!?)!xv^)N$ zdT!8a<#TRqAt(B;SzlTFF57NbV_s?5N8u+Ei+&sxWSW}#reD4OjDJ~{mgSU#J1%TL zBOy9#7zJ-PA)2WuYxS9h5s$*^>$ZSA5-S$VaonoFH8 zswf6!ep9}x;#7W3!dS!e^ZfEsT*sageobEHOzu~@q>1>x( zpNGYMm32#;89w`4UD*@PerV?RnzW@y(^@zGYh--&ZDx8!ZA9R~iE)?HO)h^GpZ9$4 z^z1Ax4f~CiI!h-Sxa*ade$Je6d;9x^uT8Joia3IQ??&s=&UI4eQwJ zx3si&+yBv6Z18Wt(~+4IPU^_YJ*!MP99*+2E#Lh8UZIYU-=ovks;|?np0$$wRpT?Y ze{!>z`8|}mecTtN)E(~C?_zdz{M4O$cw(`UTyp;1y?+|k zy1JftYjt2^(Qm$Dt?DUWDzi53OZ};EJ22#yudm*&SE63diqA44?Je(ts*)cJ4xlr` z)TL6F9g1|W)ivE)DEz*5Tgl6qQ~lD%Uw2NJrSbIreCGDPOMlPKuJGR~oxdx_m{%}e ziZ6u+6x0q1b6(oWfOZSZREAl%6if^K<7s{TkgBGVzvu*enWGsT=}tZTg>=+V)dS!em*&vt$$E>`fQu{Jr;UVi1Hy;rPG z?TG*V>vP#@=)NGuIg*_bv-9&;E@mrV7wfR{ZuMNrwbS<=Y|?57^E3(k-(jog8Df0Lw|lUifcciB`lcUY18@|e0WYhA1H7(O#`3B*H^;x@QmOY$?~RF z?_(UXS3#Qq?=n8rvE3(d?$YCD-=k*K|9O`vCy%2=(7)y>*RNYE-#?S>50(mBzVb%e zgf5%v%yRY8k1yHFf~%If+g{qX_VJyZ9cyMdEA9K;x{~F=q@!1-NQr+7yDS+{J^O9$ zj=ouPJkz7^wywW*_|VbtcRUjR>mP>tX8*M|IU=}u_WWo!-txK6)bEwOFP)Yn@@YYm ziqhE)PiMa2owi1H_xsbC3orG%)OfqheDZYP<;;M$xnWT!m#3fG+v9(kr+wr0nX>y1 z{7GgH{l1m+;h9>y!b^*MS~qv=#dtg``~D?hSLy3GujsyQfw%5o_h0$vukz=_P|^Q? z>z|(De(~?7{R9oyBa)8~oc{qziZOUXt>Mo@4L3&%VWDeF1kM>f<^XLps9W9p^LV~o zue}^-JLQv8p&aQuA7$3}2p^jH4pjE`uX)r=MpelCw?8Z`b>G_mUpX^p@7Gms zUWc_F<)<*1{k;@uG%qS|<=#1$W}IDq@1_aQ@mWU?9W}ht2WtHZo@qSy&FfXh>(kyE zy?oCsFB?{Eoazwwk729*@1#Bx?bVMsN~5jMK0Idl|4G|t#W_#g<_q^4#XelKXVH{d zbBt#GU-$my%f&UtpC!DUvo@b^biB7`^XlEx*U4Mn@p{=O`&%=1Uyk0=$La;PE^`F= z{&+Yr9Gc0;c4+1~@IGv-D$se2CB*{gf|T1^y|eb*iIX}s(^w8%CoQjTEO@3VNow8YYqFr6BYrkL$-x_@;4ifP}OqJxeK@(O$ZD4D@jY7@2JDlezreE;kH z|DI@W`t)=gpO^XEXX;OwvugNw8h1_DTY9h5SM=%oz1`o$CUnh#fzUPPXCWNI&>nDKTBJVY^KxFO)4nL$*yj4Srk}^RuG@IUfFpg{qc1VZFS*i1wI2ESoxQVt z`u_R)`%(-IGgkEfFRy#e8OOi(!yhZH?KMAbypH!jH%|~Ke73WH!eTaOi_3-J-PpG5 zpj9uT@=hG-F9VMpQnk&?Tg&?JjO2L-i(=U6K3nebBX(j(7C#2`R%zx?#%nkZ)$vo>v`ywf{W%qpk09%IE(T#o9|{ng6`oKV^HB!=~jo zSwDNNcqw03Xtva~^_Rt(ch_&kWgqp~Rk9~(MItqL&{nhU;76IPg$`Yo`fV49r0PRymp&#pxY zoSXBNr^PtEGWwo$i}C4+%PuKAn;()5qOw*l zzWcgTf9gTTe{q|(mv5P;+4!faE-UHQq_b1P%(ewfZTVU9-skN%@7go=bswL+c-&B6 zW+lJ>{@wZot!+CBADemQ*FE?0a?a{cm#qKKu>SD8%4em!RI2C1mMFTHlz%Ny`Oqo6 zN4@m|UN_CN=bu+@U(<74_3PI?avFM%N>5IxGJf8v9=GIEs8-V3N8RFqa`p}n zbeTZQZWux9kF$#e&aJ83>fU0=k-qL{#_n4JIx#VC=S}Ht2CZc6(Q>dj%@Kc1+u7pu zI&K5l8usF@E!Mvq3!eGyKd=f>TE|Rz9By`8Cd$_=blbb-U)CJfD1GHV|JI#1!S`=^ z#_rprx%q_g#KT=z(-QtaXR_l>i;{Goce9jD`Z5no*(#6C`|E3MdzZVkNM}>vOKora z@q2yh_lj2^FO>a@lM1^oYs7qahKc}JvBuAqNSpKKzE3aTD(G6~*&rr*_)oH+$8>Y% zhi9JhZMwN<+qd8=hr9JZS*h%AP(D0Of4=wi)vGJk8{OQU^YxW!$=+qlnl5oqJHOoI zs=VRWx;L+%du%IxUGe^T^6^_MR$3NUyx$UOtiJq~$*JFeoDddF`6QoSj#V&)oo(DCa;$n!ve| zK9C{nPPZo2a6LI9+dnDSHT_4)l!n9AeRJMCD}Pr1ZTsSseWtCG?G< z-k*>E@o!D;cNXitJ2h6%KOvZQwzxcSria}5@6SUIuttLpa1~vjE!AG|Oe#F(=!Az7 z|L@j)(QvPNd$+pd&5IW=mTcL#Z`LKntp~Dfk5&8XMcK?==ReP6TF_?uU035Ws=t5w z{^y?Uo%le`_oZg3M(YpHnmv2P$qk9?O*j8tFvp(L!Q%2?(9Xe!yJT97t7DiRo{?J@ zmoLgKzP>tOyF92Ucq!9jyu5SyHTjm!S1NACN`3CX+j94!U5jz~55?#(2aD5Um0xDV z4>jh${xqZ^Y{8Dt3^&aZpBqnMYya8V8qDVHaBV_YN!2S}yQ5E6+do$qUn~2k#cF2c z)>pCr;@3uaEi)Hc6|62R)$t~^Y|Z-qs+#+&vo&*r-~UlJa+W(_a;1RfsQrK2_jf;^ zn<`VN_Pv+;tIOM^wYH~}CEh)<&1_pN!(3kQEA&c=zvOMze_p%i%dG8a?cOhw|L1>4 zu<7Q<@s^>*&u>|#99a3dbjq=|x3{huSwH^bwf>;M|Gxo|^-VtW_LXoit^B#?dvEx$ zhvwFX|9_la{P8&RLmj*6F?*$^p4b%Dr*nH-@BJ3ZfY*~ewUly>^`tq8OlnPJN>DoU z_HKELS5D<*<<7-{|L)DV?EY>r+idD}?w^&FoKc5T1rDs7b?iJti?Ooc^rugM3m%$j z+Sgc+#_PU+TT^TIejDzGXJr4ifDY*G5EVGr(y}|~jQzV$?XlM!EH-zqF~8kf@XUAr z)2^!>7ME?oC-FU!@wPKPetlvuZ@by%wI`kS=*Ugv@E5vRvY}Ubd)=1`?yp~7e6ES| z`L8g2N>P&AJg&1@dRmLTyH{1@uhV{&t!cl1(&yGJVZ)okzTdXx(pHpQNw`#{}F+V4|bA$wKz%2x{-7jsNwt-SMDH#{_OXaC;W^Dp@1B>i?) zT%4NJ>AF(N-DmDB$=A;W`Ht_nzgT#Bfcx^S=PUVgw;f-UxO1zdPRyNGGrzo<^qzBN zZ-=qs#JMF24c{B?{QKl&^x(`&FE7E$XFc{V2HLBi{4BWED!uaY9wwJrmX8}RRv-Lh zY$d<{PHVw4aepi0zIE^9UcGp5FnNmD>b)nAnOzNz`*$ug`|r!YT;_`P z3;+0(Mx}+ONY}o_UAl- z)u4UXZ(I=rE`RUJwHVj`zW#P`bHTD|jzcrg9sasA zA*UlJ=hlCbrBC1f;rkuwo_6)yLB)6Vmlx`7daPdm=(73s+~?}5b2q6^Hwr#8RW|R5 z$n;AdGhX@YYA)QebIFWL#||-hc`NNXKEq|5=aPu)3hy_Tt=T{Slx%>1Mp=1z@ILKR zciKWyX1Xl%Oquy6Ff8hbnC{%uf9wDB6^7J5JX7SQEVyyTrfdHN478H+ceLyFeq9|R zc_;Gp#C+Yed$vdFBy5ho)_-NHZqma?UfY-CinMIra`9%Aq14m&A8lX%oU&otG`Cm$ z6VtyXh#y>obYN#q)^=Nr13NdRmd=m5zUSjHKcj~yCO)1rZR$rW)%^|MHL}A6(?vy> zmeilSlR4$uwGXpx9tx~q|3ay*p}`Z>@(lo&x#t}%nhU;NYZo~*)AoPp#)6IQ+1sL8 z3#RFQTKt{+VNQu%7-)B$X<+rO!+(JoF~R~UH>52*E5-8bMd>B zX7=uz>uQbj)vOmMo?CvJ{c8ymXx055(6KXl{CxbMg+DOo`zbm{e=`g2I$@msN3ALH z@{_+;9<*3iYw)&L&fB<5d`prj;~D#~i6!OMU{7q1QjfkYllib?v-$Q9nKK!;J})db zULU{Af3|}&$H~hw~ z7d*?WcfxT#Y~FS*&;im<{C_LVd07WOr8!2==tOmw@136#H{&iGo|uu>X(P=jm9}(M zamV*pRaLh0^LGAac;EQ#S>Cql^NxK1)@zm4U%8f|e0IiP*W9UMD>NQ=w5=4b=-*-) zTpuRUF2gsWcK(sg3Stl5@7#8{w50m|v7J-+Rs~x;zIt%$ z5tdJ%eoi@al6!l=!drzh3k7WIn>_DuytD9Q_}u^a#O6ziEt}UQe2-n;9C+_`WyIc% zK@-kb&;6OVKq4RvRF8cGbu23M*dOZbJFPJ1=<}x4i-ivyN-eRA+s*y(jI>;HLE75+ z3UeY)UtW35;J<~d)$YFz7N_;<_kZDgct(F-$#)Cb{SzNz{NilSNL%=B^S}4W-OD+! zGhtHf;X8&V8{L@JCkghMbcn7Dab3o{nn~UI=auzeqpdR;+;3Ybt=-=LJwsFLNynyT zQGFWGM(zInD>WW-YzpYO9qOqd^UU^i%k7<0PDlEA+H!XBemIeQH;do6lq>TP14HVK z4OT~s-_MCYxuwy@?ym6yB@>6%^5R+EE`yH8mxlAf6M?e~E$IfMTZju-#tI#?9%?@v(VXkY*j z0t$iC*L2~B?1v;+{Y?etq0mGl+H2zeAVQ# zd878e>&z6~2{3jf{;=soJbITd!$>BjK z?>%Z`F)g_1#T2n4!qfP7uA{~2ef*a;H|*cv-e%#n=`Q8HO+@5kj%n7lH z+uQl``09DyXY)Yoo18KQ7#iPzjN04|I;PrJPv~i|*6i{>oyVqJe?9TUW*4~t#uUe! zYkXXnuztQeFICGZH|4c{s9j*O=X!6InIa*A$wHQ5umAtQZ7pnI+4YG(y?pWpMMaf6 z0u#I$4IebKulNyug`M9eAaJM1M-~ymslhIM6Aw>bp`oSKrNqItNMRlet7t%=pP-1r z95n|grxP1*{!QOEZ@RC!@or;lV{6;J`Sa9+gYSl34b8tA=KJ^6>s6atZyJStJI%QH zV~2gu?WPhb|~`q>jjdQ5)vN2e(rmE z^4`?qyxU5X=9d3&dsy@iYq)n zmKkQT$ke;>mPX4jDYzE)$1a9RXhn0wp-8D|hazXWC)RMK^M<8{F$7y=3uL6bls~#8 z`86b4PxM@t>eTlITJ7<{qUWp@&pWe}Ma3X*&Y{R%q8f9S zR*IZ6PrUg@k`_=yyy59Y*l09?UR-4nmHZw)uJU@HJ;~$fFudkUFbnkM` z$5|&=7Jkp)<-KH2&7&=8wg*dIDLgo`lrKV7Amrg1X01$?$T#o({et86l{g)lXRUsd z`EBEFGw%BPo1Sc#@$cV}pj?*zjO;1vKFOsDtLb#ttl9sv_F>)z*GGNvx|X@SmaAJX z-MMz=*N48!4}M%WnX=Q*jN`4yNy4j@v&`*(bMxm<;rZFxm z4`uR;30r#c=Y7$0R{Qz1r#d|JdAKHM_lqytefD$XnzufUf4IiXuWeET%PoJ0hdQzB zT{ce!BZMoqMRKbf=+(WIlK77zIBaHzO41^o)o+@?at?Rjn6-pdLN3t zslau9fh_BjD`!N7)sH=XfBR4BL%o@kB3!RDV8Mbn;>R#pz!T|J9yU@t3Fl zX|OGOyZgPL{!CN0uaY^wa$0Qa;Y6KNRmapH=e?IYnRz{Uxq5Z4U)<-974Md;Jh}3^ zu(~d9{rMk9gx0QJQ9aR3^Y00vf-k&x0_&;1@ z8@i_8n%ujuc2_&S_a6Bq8~0G>{@2T^?r6^0+O_88wn+_-{k2~{Q_pSP{O3xm<-`V- zHnjx>)1ECZnD)%Dt>&cH&n)j}t~0M!emGXJ{EWZO#j0NxYr9py8@4RdboJE{`c*A- zy0!JPc}K|e+%HW9)7MRVvvc#On!aT=XI*AFAL#Thws@v0+PZnq6_)q)|7udd@$kh{}|DCKU7c7(~k9*oM-zr^~|PKF&|tsq&1``tc}p!7?YsC zSzTSddM0m)s#wO<%OA3Kb6ON;mh+l?|H+kp_GxC8&;*Cq92Wn!^ySVA3bQyJoI69D z(<4EHi7P$R3Y4dkuPraQmT^^kj;WUc|HrvIo-7obHJ!b6^Rta6s+=ARwzA07N1Zm; zU#UIk=~A^b?JOz=GK{U8O%z)@@3z_c zJY19eqILs|N`N}Z0t2W8T%@ou`81Cz4if`VzwmK98Uw(xx>%Ou;gyVxdO-oH#|kGa#D|My=Xn!CYd zqtEMC^2*DbOUvDNPG3K5&Nj3B#cvPW*H~wgX7aQKmRX>9?+t#abJqF(We$xwODAZ|Sy~`+_VJOW z5vHpO!q)uSVj`qr#CUwZ()D)GzX<6U)Ol}-H!R& z$*y$l$Eg5hsOQE1EID(0d9F-loQKUpJKSvuBhjBBi>un8qHOGJ}eQt30vk<1_xebrc-^gI(N>5%qPj@LxmjkFE z?FL2quQt^M1z}E03e0kxU!Gv_i?P~tYgJm*!!=q``5q2TmdX!)Oy10u8CWKAE{f$^ zimF26GT{IVZ)H$^zF?lj)Vbi=-TUI2K6>w4&E8J5K4f3`aZ$nX@2b->?rKjeczlX2 z^YN+pT}J-|)7v{DCOni6-^TOW@6Nw>^LAV|x7#QaZI;^m!G7B}vxj=~CPhs9THdR_ zd_iiYq>G}gy57-MVXd2!t}b6X(P1K|#XpgqW$dSy6_}kklrJ>FVIQZ)oTabb!9m`^ zsWGQlVy<8@D1+y%TBIo%Xpzn3COol$#jUa7@p%o0a|e}0&J_i4ra3YRt>^?*!jlh0 z&T8K$Civo=zs^}Ty;_}l26Za-~49?y!_Jpp11tu64S%6CaG>kwTE75mRG+2^5J{A>c>NmkIztkaAavj z=sKd}K3kBnb+cHb>CI(A1&=O;%*plldAR0nXabvxLZcL?#lI^Ys}477&MB>6omI=( zqX;TcPlZ`{f4-p3$k~&~Kwd3?i;1Mo(6tw14xP^9DZIl$J$jpYv{u8syH{@`oH|zXSW8{1?yWvpet~!x( zmu$XxIxr>AXb_&B6uR$F6f=cLx z{!Bs=hgs(A5Lx|6^jufy{Aq4Xu4)f{1V6bYt26u1O$F}#o(@buK_&V+d2qHCns7iu z4HQ&U*Ao>~lN(r$88CvY|NkY>u`u48yj0to$Xv# z1?@SO|4PFC&Wz^lQDy40PhPxj^P!oKFWp#C?ycGAr?TO}#ny>$xzGQr+_5?BEZ6r9 zhxuL~d1Nvty>)Y&;H(A~l>iY?&<9QSv=kxW*ZG0s=PvbMS7+q(P}tA1#o~1S-7Awe zuPF%gmO1RQvPYL@wPmjY72lj& zc2wuoG9LROEK`51f_cLap%)2EefF|3mCh-#4|6yy?oDc7De`rAxaO$xXHWIHQ(m(r zJX29%*K7|KAE;CL|5mQxY>|&9r*GhW%qr1;rHojHNfhl>~fiEvF zFMji`@Y*7^2MiaLHmGSca&DQ^AUu7Va^6d3nfj=%>qk@-8pVVP{$wiNF;jcHt7K2Q82;|h5Bf2$BcRF@yXBLrjQisx0 zusKViagk7fg?852C_U`bZ5$eNOk2*Mz~*ZQCRb1qdu7(v80^x#f&muU9P=k)50fr0 zhlgucm}c)p4^5D#8d#*178G0)uv6xDU$B0?ep5+#`SLptA63RfL#Xi?D(Ebf`naOh^oMahj~OeGmy>02)t^+19rA%#UEU-X>Y zvGWl^6C9okZTSEC&aP~g=hv#0zw|`wGq!Fv+W7T)y!}4knMSI2qVxBf-nn=0UQx%9 zpU>_8OUBRp`zF18cf!G@g5`d5XR(~P`Mmnx=Xoar!^6XOJfBm1Zh~dZo{GXd^KB}N zc9h?%Oy6<7=9zJOPx#ws-toUK?I`_lkiGr)x9$7)MqRk_)mv|;%Aec$`)hYZ=j}{= zRkwCt+Vthuo?nxCHzT>PbAkEYlHeWTF@;Ab1g<>pt-sgg(A%BQ=Q+21U&f-M(CDZ7 zVa{_K^-XoRbGL`;914rh6}|rbZu$L!e(QHT3e-cRQcYlg^__;-)H>i>aWme()WKHGgrFv^?LmM zOETMc{kML#VzJWy|3A;yAKLrlQTObqdH?^uub(LQ`C?Y*{tvDCJ<;+%Pq-gS{iAI2 zdENJY-}e^2JiyHVBy8cG=)9e(r#9a$yWMJSU|^8)GjIRjZ#(wC-FBN#vhVluc~x00 z*(LM7K9}anEM{O3_wjUb46*-jc`0&ZlIsrZ_j^81IR8BjR3lD(-QMT*(C1-}$^R75 zb8eHjouAaeVg;)3YPVi})tOwo({KxmN`MLzS2|aEW!8DF^vYGe8z+mmZZ3*dp0n^r z{j1>lUDoq|98nKitGGJi`Q1lvV&7MVKbaq{y8r9i{H3QRfBo|P`|+jO>vpQ$5#RHG z_2IgGzh2#3VEQxi=Zfo(dZrfr0I6I1vht?GSv`NVzixjy((C{KzOTGed;OlG6Dk`m z4lBLy&98Yp;o)9QMoy0f!JIjJzh0YIsv{lYSpR0@@khT}#p5L2MW)XcefK)<`>ukG z4Lg3n+kJR{$;(Rx+i#}LF6jQXupkVaoigkj*A%$*ft>K;*QV)vd%PdkeV%>aXXo6! z!m=ZR?k<%ZEPkK#ubZTmzj$7|e4WLl8wt(4hyK3Jua|yzW~TAsr#tWeeOG?C{@cy; z_TO{g*Qm$noX*?x@z{sS=h~$6B>pX`y`nuQap$=vnf#Eoo6lJ_rae5=`mpZP2 z?fLxo@B4cDb(^nCzrXYS%ag>RyP+8KO+sPs_PuX&3uV{kNX^te|8#o1+{1VK|Nkr4=+p50^ZfsR zcAPtPZrAH|(seqrc7Go7xBtJDy`J}b==!SA9haB;^H2YEm`O=5>_eY z-Hs=-Z8as`Uwps+ecyLii*I(7AG_ma{yFnms>B>~ePnERIsbn7{aV4i`L*9TzdxDm z&o}*J%Rd{vUm)i!U}TDX*ke28X7I;nv-A6|sQb;4$TRKUT=m<1Q%^up{{Qx+($nnA z?-aVLoUAxlllyk-^=E;iv5$=RB@~9euLv^yI8}VJSo-0wkI$&jEI&8XSbb93oIAPO z?;iU9>-v7XJ5|p)uc$v3tTvo1m!G!rx$NiUW1i0xBdQM1wB5I%=l7T8_Od^3XD*+6 zD7B6={8{Dlxn)sz(&zm=6~52t&zbPH+mCxHmBiBbBp%x#)4BG~g1GOuve)l8^20v& z)|L`;t!$i)BoLjY(cm4DBhC`7%^#soC`}cMI@u~9vKD0mll=u03{lCv27OyCN#a2Bh zv9je*|5JXk$cVa)hk970rsaT2Smj!sD<1Pj1$8b3T>JlR`+nDoZxx*Wt^D(%U(2Q6 z4A%Jnb$z|-syS+1tTJMiG0*1~=$xqg&@As_qjT;7bG**M85|qknZ7c(PpJws*swrp z@8@&Y`tywQ@9inDOntU$^*XNqeb({-$%UCjEVO^QWU`XXos;5vPm_F736ge3aJ8ufrb^xoDm?ORx7$+j9?2cb)97`?8p${ps!FcHcIB zI4o$l=FNttLz#|mRyMRsXzT8oe12Z>7Vj{hh{!{eCW)$?a^9orJHGhr#-uNub=l#1L~`$r za}~|4M?cT|zGuh3HR-n6F*XJ?7l9dkV` zAKVd9XV|}DYyFW)(|-o7{VQKx^RVuPvpg49e&y-tLx0bt+X=sW+;1^`8%im2JfCM?6RjJXzCIDc+_TG3l_j(8tBe zp!B<%>tLI`MC<00j%}R`Op?s=4$V9ixw27X&QxmwSx%1yFIhy+330QlE#XS{%}-L( zVC1~w;qY+HrlT_t-Hg20B+w*sZqiyQk#kvZBCK>~9E#j|XwxCd<(_$)PO1quO=Z1z z^_UT7t30=n$dBT4mg-kUK*trd%Xb@2DGLkOq%FTkxi|IZr>Cct?M~TOSML3N_r0I3 z`s>VTM{kPn`_Sq!YwA2u;#uiFQGd$sHAf{+*-ndfNqgE*{$k^Wq@^<6s(x3G9O@F) z&^hSA&%OEk{{MdqJE#855=)O*mKr`QLUZT$d)3Oar<`jywu@Ka39!GpS5|}n=jmO& zQF9C@CZ)bO9sjS$rfKRzcllbAKXaZnxhlVv=H?13iP)If`fhdn->f6<7Dp$2I>;{H z^E$#PX=ZyXd-dUs(;21-JSktY{PGcJQ2IQ^(iqihoRBc_bVct|(Z4cc>0chNDf!&R z`&a0(@nhS^QrdskR~*%TG%>=@*ze-|q|9?(){jMhrZ8rmnxa|QDO#;_?ndyWFBX5h zMJvOO-E^L`H|a&Zruk!y^H--fu&5XWF-1P)@tzMVwkN9Ow|v+=^B;e=@jZ_P_D}y6 z{8gS7_w`%-pMA%jW|&t?sBijfvp@Ui-`P)H>^6y|Z|ze*D+!V{Qqoz zU9n2%QOVEiqn>k4;XiGvvs6-E(tqQTLq)S~=Wcv(xMm zL4K0lv5NU!YnDS2nAbxc#=y6szO9>|Tn%RPXf+(Rel2 zLTgTG#$of@LMId%TQ{E)OFzRc(6CDPh{hb>-mtX=(;{9x{osE`=cDJxWp|Iz1{fmX|LayOVLK6;5V963acjNIS+Zc)Vo2Ms<7U`V3aayoD zMMquYxWC22mJ{OBb{MChrC+~o z59$d_jNAKHKL20BOp}|l2^Xd|u*`CGc=+aKW6;Ajk-Dc! zR1_K~3I$kr^W6@z@ZPS_qT0Gyow?^wvHaJT%YNM-<{WEmI?E7tt}e8FD%-b3 zkJu}=a$IdO*4Y_R`0$G8rH8V`EKAoMy?KybUdHhzcV9(B#?|b?FkRbEk*kkNim|Nm znYez*?%eOY)*d;@#vwWRjTLN~c*-k*7fJMH{D+5QK-6QX{*>omT8`(JP0k4F`9pH1ia zTfTIE`TzNunZnzD{Oz0nVUMBn{`mJ5hnS52+Pr-9Y4^UcZsTKu&sO$COIRfoZaXel zeMC8HuDU^fBENx!p?>9qMs}sW^8bGve`wT~RCuCz#{2}%WB1C!<0?~+91faQXq;Vm zY~GCsrl5TtdpwZABe(rcJ8W!cY_D2-Ec}-E$Gm+%pZz$sXTs%u zpbqekbw?$aSlte_(7rzZP$ZMnL!E~U3d*7z0xY!41+0W799SV7U~yY*hW4D^s>`1n zg(f((fr=|f7tkc#6#b@iTE1#aCBM0iCi)+HAniwl*puRbao?joR5 zS;1a=X^Cgy-12$P&-?eSOFi|==em1W+4xtzE0#gDJ+>t(-3S|+LXO_dAFS#h6Z z&!=Wv#>h;|e?J~SzILdU`*3Q%{HNWA`K$#(K_!)gD-%cho6e#GjO;EJVK>(A`}OKW z^2amtdV0*i)JJw3|FS=p7`DgqYwt&n?mM;LZmQ37w)tkZ^T{OdM^=Aj&nxbqQ+4v5 z(U7Dxki6%?7594+EPub*tjzhTQ`O|Niq1I+_N=+;A7Uzu z*L^s~C3Yzy!0`UVa=VyHyU$&Fzu&X|bb8OkU`@&T&7gk6(LJ*(#ojjBdU?usZ_o+< zb0&U+MY4X{r?WNlD$S=o`JgImVRXLz`^MvP(vCMCzuk0NkNdyjaT#H~{rofRC(p}E z7Ywk_zCQg>BvTV(;KMgP42Pag=1M=g+MCCQD}C(VC-Ly;pONDw`2k`q5X<+q8QRMb5lXa7a?&`~CX=KdS!yeBR#e)cf*X zT=iSg>y=xZD%KQiSoE-T$%-zhKIIO&Mj@v7Hr#lPRY%rO0Ov4%J^@B2lcFJy_O&rv%im;YggX?B>) z+G+O+#L}lc-}CugcS+mr$8XxC^EwKyNNX#o&#Op^+5hL$>BFf@+eH=cWgd4@V&eUH z@DxkU3id?_j>ovzg}vTwzRt)b|MgI&$(7^Yai1o29O`19CbFdYbeEjA#*d9H4e}2+ z&Phn^_;$GV({V4!>!qO4!Vu*Hrwp2E&19BmZk*KXYW8qLMFPLUv4fxyfu@36`hQi0 z8U6B;=Oi+wUXLyBt@v{OO{Hg5&;K|W*bA61uq&09~uJQKkn><3EU2Rt~hXwBL9l>6^>(6~tdkw{lC+gNAj4_n_KYq#Cf+V`n?`cJ99^=ltgR(kp*j~O+;`3|ow}&P+lXpgGvJ!6H8wt}U2$XpuXUkOV7u#B6EWvVv{#f3|AQ>2+E@`LMgj z9Mhda0ZSquJ~QzN)7rIb*Q`HBE_(KfDut<4ES+5bKl0)X%i^?@7ahud8D7UK#OGa( zh^Wl+dZg^pVw)TBP(40^E1|I6^^u?T+mP$Q8TNl14qoaP-ThE#@!gME{6ANUz3)_? z*Acy^+CKSM&xg&=7mkGMt?-<3L3e=r<9h4S&%ovY}`&lP1oq752 zrk@YD`1y%G-ts8xvDNadi}N>^^H#4)xxD_Weg3^Y99>7P#ZF%R6qvoMZ8E579mV2y z%f4^R|Jl~%c_O`gkIXp!<=eL~9iwXJ%jb)-YPQyGyZ3R)BWL%ANqaxD=x{tVxKq<& zyGt@$a{j8fZ^N=D&I}KT?^(X}SbmvB9eYCH@tWng-m^b*KQp^<_FVnT*}9Cao7FzM z{#cM4WRcBdz%0l#_0Y`=HksvI>CVNU*0HE4Fdh@Hd!Rk%rW&Y#>)~T;-F)%koLaBF zpIKTr>l{3?z94L-_r>*Eb42&H%?`2i3F^1`bi!le-js;Qg!hJio)1N5tJ&I_pOh&& zp*T_8PyFfC*n3yg`L{^t?=d`Ny6e!S*-f(rF$V4rNT@6*Fk{oMRdEfo@Lr)gr+3L$ z33uqYIbwF=!b}#Cb5@)0x!GQylXqxk-{$o5eKs4!ugO)b95>0|ez$D#yzLij6yE^4*0|XEUCiP4|S1(1ZL0o9eg_!vva(kGye3+w=SP z=g4o$st9-{^Z~njWKjpUDO3kUa++WWB+y9rp*45Np%4=$W p(40&EbEK7Zf6xE+&Hg|Cug79XcGoulXJBAp@O1TaS?83{1OWI$x8eW* literal 0 HcmV?d00001 diff --git a/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html new file mode 100644 index 00000000..40fcfb12 --- /dev/null +++ b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html @@ -0,0 +1,260 @@ + + + +
+ Discord Bot: Connect Discord users to LNbits. +
+

+ Connect your LNbits instance to a Discord Bot leveraging LNbits as a community based lightning node.
+ + Created by, Chris Lennon
+ + Based on User Manager, by Ben Arc +

+
+
+
+ + + + + GET + /discordbot/api/v1/users +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.base_url }}discordbot/api/v1/users -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/api/v1/users/<user_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.base_url + }}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/api/v1/wallets/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON wallet data +
Curl example
+ curl -X GET {{ request.base_url + }}discordbot/api/v1/wallets/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/api/v1/wallets<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON a wallets transactions +
Curl example
+ curl -X GET {{ request.base_url + }}discordbot/api/v1/wallets<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /discordbot/api/v1/users +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"admin_id": <string>, "user_name": <string>, + "wallet_name": <string>,"discord_id": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "name": <string>, "admin": + <string>, "discord_id": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}discordbot/api/v1/users -d + '{"admin_id": "{{ user.id }}", "wallet_name": <string>, + "user_name": <string>, "discord_id": <string>}' -H "X-Api-Key: {{ + user.wallets[0].inkey }}" -H "Content-type: application/json" + +
+
+
+ + + + POST + /discordbot/api/v1/wallets +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"user_id": <string>, "wallet_name": <string>, + "admin_id": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "admin": <string>, "name": + <string>, "user": <string>, "adminkey": <string>, + "inkey": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}discordbot/api/v1/wallets -d + '{"user_id": <string>, "wallet_name": <string>, + "admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey + }}" -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /discordbot/api/v1/users/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.base_url + }}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /discordbot/api/v1/wallets/<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.base_url + }}discordbot/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /discordbot/api/v1/extensions +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}discordbot/api/v1/extensions -d + '{"userid": <string>, "extension": <string>, "active": + <integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H + "Content-type: application/json" + +
+
+
+
diff --git a/lnbits/extensions/discordbot/templates/discordbot/index.html b/lnbits/extensions/discordbot/templates/discordbot/index.html new file mode 100644 index 00000000..782f8bb6 --- /dev/null +++ b/lnbits/extensions/discordbot/templates/discordbot/index.html @@ -0,0 +1,464 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+ This extension is designed to be used through its API by a Discord Bot, + currently you have to install the bot + yourself
+ + Soon™ there will be a much easier one-click install discord bot... +
+
+ + + +
+
+
Users
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Wallets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits Discord Bot Extension + +
+
+ + + {% include "discordbot/_api_docs.html" %} + +
+
+ + + + + + + + + Create User + Cancel + + + + + + + + + + + Create Wallet + Cancel + + + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/discordbot/views.py b/lnbits/extensions/discordbot/views.py new file mode 100644 index 00000000..a5395e21 --- /dev/null +++ b/lnbits/extensions/discordbot/views.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.params import Depends +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import discordbot_ext, discordbot_renderer + + +@discordbot_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return discordbot_renderer().TemplateResponse( + "discordbot/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/discordbot/views_api.py b/lnbits/extensions/discordbot/views_api.py new file mode 100644 index 00000000..64d1df1a --- /dev/null +++ b/lnbits/extensions/discordbot/views_api.py @@ -0,0 +1,127 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core import update_user_extension +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import discordbot_ext +from .crud import ( + create_discordbot_user, + create_discordbot_wallet, + delete_discordbot_user, + delete_discordbot_wallet, + get_discordbot_user, + get_discordbot_users, + get_discordbot_users_wallets, + get_discordbot_wallet, + get_discordbot_wallet_transactions, + get_discordbot_wallets, +) +from .models import CreateUserData, CreateUserWallet + +# Users + + +@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK) +async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)): + user_id = wallet.wallet.user + return [user.dict() for user in await get_discordbot_users(user_id)] + + +@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK) +async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)): + user = await get_discordbot_user(user_id) + return user.dict() + + +@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED) +async def api_discordbot_users_create( + data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await create_discordbot_user(data) + full = user.dict() + full["wallets"] = [ + wallet.dict() for wallet in await get_discordbot_users_wallets(user.id) + ] + return full + + +@discordbot_ext.delete("/api/v1/users/{user_id}") +async def api_discordbot_users_delete( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await get_discordbot_user(user_id) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + await delete_discordbot_user(user_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +# Activate Extension + + +@discordbot_ext.post("/api/v1/extensions") +async def api_discordbot_activate_extension( + extension: str = Query(...), userid: str = Query(...), active: bool = Query(...) +): + user = await get_user(userid) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + update_user_extension(user_id=userid, extension=extension, active=active) + return {"extension": "updated"} + + +# Wallets + + +@discordbot_ext.post("/api/v1/wallets") +async def api_discordbot_wallets_create( + data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await create_discordbot_wallet( + user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id + ) + return user.dict() + + +@discordbot_ext.get("/api/v1/wallets") +async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): + admin_id = wallet.wallet.user + return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)] + + +@discordbot_ext.get("/api/v1/transactions/{wallet_id}") +async def api_discordbot_wallet_transactions( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + return await get_discordbot_wallet_transactions(wallet_id) + + +@discordbot_ext.get("/api/v1/wallets/{user_id}") +async def api_discordbot_users_wallets( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + return [ + s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id) + ] + + +@discordbot_ext.delete("/api/v1/wallets/{wallet_id}") +async def api_discordbot_wallets_delete( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + get_wallet = await get_discordbot_wallet(wallet_id) + if not get_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + await delete_discordbot_wallet(wallet_id, get_wallet.user) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) From 436c3dd51ab9347fd399328aaadd7c1f9681aabb Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 6 May 2022 20:10:22 -0300 Subject: [PATCH 015/209] fix: spark fee when pay_invoice pending. --- lnbits/wallets/spark.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 00bf6d3c..c039990e 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -129,7 +129,9 @@ class SparkWallet(Wallet): if pay["status"] == "failed": return PaymentResponse(False, None, 0, None, str(exc)) elif pay["status"] == "pending": - return PaymentResponse(None, payment_hash, 0, None, None) + return PaymentResponse( + None, payment_hash, fee_limit_msat, None, None + ) elif pay["status"] == "complete": r = pay r["payment_preimage"] = pay["preimage"] From c04e1de4120ceea0bf648af8dcd5b4846d4f0dbe Mon Sep 17 00:00:00 2001 From: Joel Klabo Date: Sat, 7 May 2022 17:27:58 -0700 Subject: [PATCH 016/209] Fix typo in User Manager API Docs --- .../extensions/usermanager/templates/usermanager/_api_docs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index f3b1e8bd..9d2901f6 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -14,7 +14,7 @@ extension allows the creation and management of users and wallets.
For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the - develpoers stack as the user and wallet manager.
+ developers stack as the user and wallet manager.
Created by, Ben Arc From f08fbc2487948ef2fce951c5f55fa9bf714b76ff Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 10 May 2022 21:48:24 +0200 Subject: [PATCH 017/209] core: check_invoice_status for outgoing payments --- lnbits/core/services.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 3d54e218..875f7644 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -321,7 +321,10 @@ async def check_invoice_status( payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - status = await WALLET.get_invoice_status(payment.checking_id) + if payment.is_out: + status = await WALLET.get_payment_status(payment.checking_id) + else: + status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: return status if payment.is_out and status.failed: From ebd6d6d58b7b54d9171d0bd303b80a86525a264d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 10 May 2022 22:00:50 +0200 Subject: [PATCH 018/209] key check --- lnbits/core/views/api.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0e88b5c8..ff46f29e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -365,14 +365,13 @@ async def api_payments_sse( @core_app.get("/api/v1/payments/{payment_hash}") async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): - wallet = None - try: - if X_Api_Key.extra: - print("No key") - except: + if X_Api_Key is not None: wallet = await get_wallet_for_key(X_Api_Key) + else: + wallet = None payment = await get_standalone_payment(payment_hash) - await check_invoice_status(payment.wallet_id, payment_hash) + if payment: + await check_invoice_status(payment.wallet_id, payment_hash) payment = await get_standalone_payment(payment_hash) if not payment: raise HTTPException( From a0a2ecd42418a44b851316672788a235a598d71f Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 11 May 2022 16:30:15 +0100 Subject: [PATCH 019/209] protect listing and deleteing user and wallets --- .../usermanager/templates/usermanager/index.html | 8 ++++---- lnbits/extensions/usermanager/views_api.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html index 6fbe9686..da11ad44 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/index.html +++ b/lnbits/extensions/usermanager/templates/usermanager/index.html @@ -299,7 +299,7 @@ .request( 'GET', '/usermanager/api/v1/users', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(function (response) { self.users = response.data.map(function (obj) { @@ -362,7 +362,7 @@ .request( 'DELETE', '/usermanager/api/v1/users/' + userId, - self.g.user.wallets[0].inkey + self.g.user.wallets[0].adminkey ) .then(function (response) { self.users = _.reject(self.users, function (obj) { @@ -389,7 +389,7 @@ .request( 'GET', '/usermanager/api/v1/wallets', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(function (response) { self.wallets = response.data.map(function (obj) { @@ -447,7 +447,7 @@ .request( 'DELETE', '/usermanager/api/v1/wallets/' + userId, - self.g.user.wallets[0].inkey + self.g.user.wallets[0].adminkey ) .then(function (response) { self.wallets = _.reject(self.wallets, function (obj) { diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py index 8c652385..a36b36bb 100644 --- a/lnbits/extensions/usermanager/views_api.py +++ b/lnbits/extensions/usermanager/views_api.py @@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException from lnbits.core import update_user_extension from lnbits.core.crud import get_user -from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from . import usermanager_ext from .crud import ( @@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet @usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK) -async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)): user_id = wallet.wallet.user return [user.dict() for user in await get_usermanager_users(user_id)] @@ -52,7 +52,7 @@ async def api_usermanager_users_create( @usermanager_ext.delete("/api/v1/users/{user_id}") async def api_usermanager_users_delete( - user_id, wallet: WalletTypeInfo = Depends(get_key_type) + user_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): user = await get_usermanager_user(user_id) if not user: @@ -93,7 +93,7 @@ async def api_usermanager_wallets_create( @usermanager_ext.get("/api/v1/wallets") -async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)): admin_id = wallet.wallet.user return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)] @@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions( @usermanager_ext.get("/api/v1/wallets/{user_id}") async def api_usermanager_users_wallets( - user_id, wallet: WalletTypeInfo = Depends(get_key_type) + user_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): return [ s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id) @@ -116,7 +116,7 @@ async def api_usermanager_users_wallets( @usermanager_ext.delete("/api/v1/wallets/{wallet_id}") async def api_usermanager_wallets_delete( - wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) + wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): get_wallet = await get_usermanager_wallet(wallet_id) if not get_wallet: From caa23e6c9d6b56626416f831dd5dc9b08ac7047c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 16 May 2022 11:21:30 +0100 Subject: [PATCH 020/209] key issues fixed --- lnbits/core/static/js/wallet.js | 5 +++-- lnbits/core/views/api.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 8e782c54..ed997ab4 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -617,11 +617,12 @@ new Vue({ }) }, updateWalletName: function () { + console.log(this.newName, this.g) let newName = this.newName + let adminkey = this.g.wallet.adminkey if (!newName || !newName.length) return - // let data = {name: newName} LNbits.api - .request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.adminkey, {}) + .request('PUT', '/api/v1/wallet/' + newName, adminkey, {}) .then(res => { this.newName = '' this.$q.notify({ diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0e88b5c8..3be3a460 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx -from fastapi import Query, Request, Header +from fastapi import Header, Query, Request from fastapi.exceptions import HTTPException from fastapi.param_functions import Depends from fastapi.params import Body @@ -23,7 +23,7 @@ from lnbits.decorators import ( WalletInvoiceKeyChecker, WalletTypeInfo, get_key_type, - require_admin_key + require_admin_key, ) from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.requestvars import g @@ -99,7 +99,7 @@ async def api_update_balance( @core_app.put("/api/v1/wallet/{new_name}") async def api_update_wallet( - new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker()) + new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) ): await update_wallet(wallet.wallet.id, new_name) return { From c8aaf3377faac414f3a8ad97a373783f88107d40 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 16 May 2022 11:23:34 +0100 Subject: [PATCH 021/209] clean logs --- lnbits/core/static/js/wallet.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index ed997ab4..f872ad09 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -617,7 +617,6 @@ new Vue({ }) }, updateWalletName: function () { - console.log(this.newName, this.g) let newName = this.newName let adminkey = this.g.wallet.adminkey if (!newName || !newName.length) return From 98891b7680329d3caf4531a6cb736cf7cf35766e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 17 May 2022 12:16:41 +0100 Subject: [PATCH 022/209] get bookmark promp fired on new wallet --- lnbits/core/static/js/wallet.js | 5 +---- lnbits/core/templates/core/wallet.html | 13 ++++++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index f872ad09..29a1025d 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -691,10 +691,7 @@ new Vue({ }, mounted: function () { // show disclaimer - if ( - this.$refs.disclaimer && - !this.$q.localStorage.getItem('lnbits.disclaimerShown') - ) { + if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) { this.disclaimerDialog.show = true this.$q.localStorage.set('lnbits.disclaimerShown', true) } diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index e9c6e496..9453e1bf 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -706,8 +706,7 @@ - {% if service_fee > 0 %} -
+
Warning
@@ -720,10 +719,10 @@

This service is in BETA, and we hold no responsibility for people losing - access to funds. To encourage you to run your own LNbits installation, - any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} - will incur a charge of - {{ service_fee }}% service fee per week. + access to funds. {% if service_fee > 0 %} To encourage you to run your + own LNbits installation, any balance on {% raw %}{{ + disclaimerDialog.location.host }}{% endraw %} will incur a charge of + {{ service_fee }}% service fee per week. {% endif %}

- {% endif %} {% endblock %} + {% endblock %}
From bb5b04b307ec519536361f9309f57ad79e0bdc84 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 21:55:39 +0100 Subject: [PATCH 023/209] Added max amount to withdraw creation --- lnbits/extensions/withdraw/models.py | 2 +- lnbits/extensions/withdraw/templates/withdraw/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index a03c7db8..72b75fb8 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -12,7 +12,7 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1) + uses: int = Query(..., ge=1, le=250) wait_time: int = Query(..., ge=1) is_unique: bool diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 13673028..c7c216ce 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -132,7 +132,7 @@ - +
From ca8f649fd63e71b8ce655bb50214dfb1a4a06f00 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 22:02:54 +0100 Subject: [PATCH 024/209] Added check for error message --- lnbits/extensions/withdraw/models.py | 2 +- lnbits/extensions/withdraw/views_api.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index 72b75fb8..a03c7db8 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -12,7 +12,7 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1, le=250) + uses: int = Query(..., ge=1) wait_time: int = Query(..., ge=1) is_unique: bool diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 8dd9e340..c48a26b7 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -71,6 +71,11 @@ async def api_link_create_or_update( link_id: str = None, wallet: WalletTypeInfo = Depends(require_admin_key), ): + if data.uses > 250: + raise HTTPException( + detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST + ) + if data.min_withdrawable < 1: raise HTTPException( detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST From d951ac435f29fbba049e174bae2b03ba85f3863e Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 22:08:33 +0100 Subject: [PATCH 025/209] Added check to form --- lnbits/extensions/withdraw/templates/withdraw/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index c7c216ce..0ce8507b 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -180,7 +180,7 @@ - +
Date: Thu, 19 May 2022 23:05:34 -0400 Subject: [PATCH 027/209] Fix GET transactions endpoint in usermanager docs --- .../usermanager/templates/usermanager/_api_docs.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index 9d2901f6..34b3c39b 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -97,7 +97,7 @@ GET - /usermanager/api/v1/wallets<wallet_id>
Headers
{"X-Api-Key": <string>} @@ -109,7 +109,7 @@
Curl example
curl -X GET {{ request.base_url - }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{ + }}usermanager/api/v1/transactions/<wallet_id> -H "X-Api-Key: {{ user.wallets[0].inkey }}"
From b4ad0e101d4a3ec139b099d573cd303c7460ca4b Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Fri, 20 May 2022 14:35:00 +0100 Subject: [PATCH 028/209] Revert "Check invoice status outgoing" --- lnbits/core/services.py | 5 +---- lnbits/core/views/api.py | 11 ++++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 875f7644..3d54e218 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -321,10 +321,7 @@ async def check_invoice_status( payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - if payment.is_out: - status = await WALLET.get_payment_status(payment.checking_id) - else: - status = await WALLET.get_invoice_status(payment.checking_id) + status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: return status if payment.is_out and status.failed: diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 85de97cd..3be3a460 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -365,13 +365,14 @@ async def api_payments_sse( @core_app.get("/api/v1/payments/{payment_hash}") async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): - if X_Api_Key is not None: + wallet = None + try: + if X_Api_Key.extra: + print("No key") + except: wallet = await get_wallet_for_key(X_Api_Key) - else: - wallet = None payment = await get_standalone_payment(payment_hash) - if payment: - await check_invoice_status(payment.wallet_id, payment_hash) + await check_invoice_status(payment.wallet_id, payment_hash) payment = await get_standalone_payment(payment_hash) if not payment: raise HTTPException( From b4a6b5a1d9e2ff9275cc068ce403ceeb5180bac1 Mon Sep 17 00:00:00 2001 From: calle <93376500+callebtc@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:25:37 +0200 Subject: [PATCH 029/209] serviced fix doc (#645) --- docs/guide/installation.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 2806a4f5..7184d46b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -78,17 +78,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo [Unit] Description=LNbits -#Wants=lnd.service # you can uncomment these lines if you know what you're doing -#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service) +# you can uncomment these lines if you know what you're doing +# it will make sure that lnbits starts after lnd (replace with your own backend service) +#Wants=lnd.service +#After=lnd.service [Service] -WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation -ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here -User=bitcoin # replace with the user that you're running lnbits on +# replace with the absolute path of your lnbits installation +WorkingDirectory=/home/bitcoin/lnbits +# same here +ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 +# replace with the user that you're running lnbits on +User=bitcoin Restart=always TimeoutSec=120 RestartSec=30 -Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time +# this makes sure that you receive logs in real time +Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target From 7c4ce9bf961aa5f26ca8b9f2ab6ca85d7bee5388 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 1 Jun 2022 12:53:47 +0100 Subject: [PATCH 030/209] Extension: add example extension (#644) * add example ext files * remove from extension list --- lnbits/extensions/example/README.md | 11 ++++ lnbits/extensions/example/__init__.py | 19 ++++++ lnbits/extensions/example/example.config.json | 6 ++ lnbits/extensions/example/migrations.py | 10 ++++ lnbits/extensions/example/models.py | 6 ++ .../example/templates/example/index.html | 59 +++++++++++++++++++ lnbits/extensions/example/views.py | 18 ++++++ lnbits/extensions/example/views_api.py | 34 +++++++++++ 8 files changed, 163 insertions(+) create mode 100644 lnbits/extensions/example/README.md create mode 100644 lnbits/extensions/example/__init__.py create mode 100644 lnbits/extensions/example/example.config.json create mode 100644 lnbits/extensions/example/migrations.py create mode 100644 lnbits/extensions/example/models.py create mode 100644 lnbits/extensions/example/templates/example/index.html create mode 100644 lnbits/extensions/example/views.py create mode 100644 lnbits/extensions/example/views_api.py diff --git a/lnbits/extensions/example/README.md b/lnbits/extensions/example/README.md new file mode 100644 index 00000000..27729459 --- /dev/null +++ b/lnbits/extensions/example/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py new file mode 100644 index 00000000..29a0f0b6 --- /dev/null +++ b/lnbits/extensions/example/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_example") + +example_ext: APIRouter = APIRouter( + prefix="/example", + tags=["example"] +) + + +def example_renderer(): + return template_renderer(["lnbits/extensions/example/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/example/example.config.json b/lnbits/extensions/example/example.config.json new file mode 100644 index 00000000..b8eec193 --- /dev/null +++ b/lnbits/extensions/example/example.config.json @@ -0,0 +1,6 @@ +{ + "name": "Build your own!!", + "short_description": "Join us, make an extension", + "icon": "info", + "contributors": ["github_username"] +} diff --git a/lnbits/extensions/example/migrations.py b/lnbits/extensions/example/migrations.py new file mode 100644 index 00000000..99d7c362 --- /dev/null +++ b/lnbits/extensions/example/migrations.py @@ -0,0 +1,10 @@ +# async def m001_initial(db): +# await db.execute( +# f""" +# CREATE TABLE example.example ( +# id TEXT PRIMARY KEY, +# wallet TEXT NOT NULL, +# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} +# ); +# """ +# ) diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py new file mode 100644 index 00000000..0347a06f --- /dev/null +++ b/lnbits/extensions/example/models.py @@ -0,0 +1,6 @@ +# from pydantic import BaseModel + +# class Example(BaseModel): +# id: str +# wallet: str + diff --git a/lnbits/extensions/example/templates/example/index.html b/lnbits/extensions/example/templates/example/index.html new file mode 100644 index 00000000..d732ef37 --- /dev/null +++ b/lnbits/extensions/example/templates/example/index.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + + +
+ Frameworks used by {{SITE_TITLE}} +
+ + + {% raw %} + + + {{ tool.name }} + {{ tool.language }} + + {% endraw %} + + + +

+ A magical "g" is always available, with info about the user, wallets and + extensions: +

+ {% raw %}{{ g }}{% endraw %} +
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/example/views.py b/lnbits/extensions/example/views.py new file mode 100644 index 00000000..252b4726 --- /dev/null +++ b/lnbits/extensions/example/views.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import example_ext, example_renderer + +templates = Jinja2Templates(directory="templates") + + +@example_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return example_renderer().TemplateResponse( + "example/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py new file mode 100644 index 00000000..f144fe76 --- /dev/null +++ b/lnbits/extensions/example/views_api.py @@ -0,0 +1,34 @@ +# views_api.py is for you API endpoints that could be hit by another service + +# add your dependencies here + +# import httpx +# (use httpx just like requests, except instead of response.ok there's only the +# response.is_error that is its inverse) + +from . import example_ext + +# add your endpoints here + +@example_ext.get("/api/v1/tools") +async def api_example(): + """Try to add descriptions for others.""" + tools = [ + { + "name": "fastAPI", + "url": "https://fastapi.tiangolo.com/", + "language": "Python", + }, + { + "name": "Vue.js", + "url": "https://vuejs.org/", + "language": "JavaScript", + }, + { + "name": "Quasar Framework", + "url": "https://quasar.dev/", + "language": "JavaScript", + }, + ] + + return tools From f8400512f7ecaf1b8109e3c1655013451f859fb9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 1 Jun 2022 14:53:05 +0200 Subject: [PATCH 031/209] black formating --- conv.py | 1 + lnbits/bolt11.py | 12 ++--- lnbits/core/views/api.py | 6 ++- lnbits/core/views/generic.py | 4 +- lnbits/decorators.py | 24 +++++++--- lnbits/extensions/discordbot/models.py | 2 + lnbits/extensions/discordbot/views_api.py | 4 +- lnbits/extensions/example/__init__.py | 5 +-- lnbits/extensions/example/models.py | 1 - lnbits/extensions/example/views_api.py | 1 + lnbits/extensions/jukebox/crud.py | 2 +- lnbits/extensions/jukebox/views_api.py | 3 +- lnbits/extensions/livestream/views.py | 1 + lnbits/extensions/offlineshop/helpers.py | 4 +- lnbits/extensions/paywall/views_api.py | 7 +-- lnbits/extensions/streamalerts/views_api.py | 2 +- lnbits/extensions/withdraw/lnurl.py | 20 ++++++--- lnbits/extensions/withdraw/views.py | 3 +- lnbits/extensions/withdraw/views_api.py | 6 +-- lnbits/helpers.py | 4 +- lnbits/settings.py | 4 +- lnbits/wallets/eclair.py | 50 ++++++++++----------- lnbits/wallets/macaroon/__init__.py | 2 +- lnbits/wallets/macaroon/macaroon.py | 16 ++++--- tests/conftest.py | 3 +- tests/core/views/test_generic.py | 1 + tests/extensions/bleskomat/conftest.py | 15 +++++-- tests/helpers.py | 5 ++- 28 files changed, 127 insertions(+), 81 deletions(-) diff --git a/conv.py b/conv.py index 159c7dc0..aa66a998 100644 --- a/conv.py +++ b/conv.py @@ -1,6 +1,7 @@ import psycopg2 import sqlite3 import os + # Python script to migrate an LNbits SQLite DB to Postgres # All credits to @Fritz446 for the awesome work diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 74f73963..e5221984 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -165,7 +165,7 @@ def lnencode(addr, privkey): if addr.amount: amount = Decimal(str(addr.amount)) # We can only send down to millisatoshi. - if amount * 10 ** 12 % 10: + if amount * 10**12 % 10: raise ValueError( "Cannot encode {}: too many decimal places".format(addr.amount) ) @@ -270,7 +270,7 @@ class LnAddr(object): def shorten_amount(amount): """Given an amount in bitcoin, shorten it""" # Convert to pico initially - amount = int(amount * 10 ** 12) + amount = int(amount * 10**12) units = ["p", "n", "u", "m", ""] for unit in units: if amount % 1000 == 0: @@ -289,7 +289,7 @@ def _unshorten_amount(amount: str) -> int: # * `u` (micro): multiply by 0.000001 # * `n` (nano): multiply by 0.000000001 # * `p` (pico): multiply by 0.000000000001 - units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3} + units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3} unit = str(amount)[-1] # BOLT #11: @@ -348,9 +348,9 @@ def _trim_to_bytes(barr): def _readable_scid(short_channel_id: int) -> str: return "{blockheight}x{transactionindex}x{outputindex}".format( - blockheight=((short_channel_id >> 40) & 0xffffff), - transactionindex=((short_channel_id >> 16) & 0xffffff), - outputindex=(short_channel_id & 0xffff), + blockheight=((short_channel_id >> 40) & 0xFFFFFF), + transactionindex=((short_channel_id >> 16) & 0xFFFFFF), + outputindex=(short_channel_id & 0xFFFF), ) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 3be3a460..3b2f8b3a 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -391,7 +391,11 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): return {"paid": False} if wallet and wallet.id == payment.wallet_id: - return {"paid": not payment.pending, "preimage": payment.preimage, "details": payment} + return { + "paid": not payment.pending, + "preimage": payment.preimage, + "details": payment, + } return {"paid": not payment.pending, "preimage": payment.preimage} diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 16a2fbac..d9687e16 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str): redeem_lnurl_withdraw(bc.wallet, bc.url) -@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet") +@core_html_routes.get( + "/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet" +) async def lnurlwallet(request: Request): async with db.connect() as conn: account = await create_account(conn=conn) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 76cb8a54..d6f73f40 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -13,7 +13,11 @@ from starlette.requests import Request from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.models import User, Wallet from lnbits.requestvars import g -from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS +from lnbits.settings import ( + LNBITS_ALLOWED_USERS, + LNBITS_ADMIN_USERS, + LNBITS_ADMIN_EXTENSIONS, +) class KeyChecker(SecurityBase): @@ -122,7 +126,7 @@ async def get_key_type( # 0: admin # 1: invoice # 2: invalid - pathname = r['path'].split('/')[1] + pathname = r["path"].split("/")[1] if not api_key_header and not api_key_query: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) @@ -133,8 +137,12 @@ async def get_key_type( checker = WalletAdminKeyChecker(api_key=token) await checker.__call__(r) wallet = WalletTypeInfo(0, checker.wallet) - if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( + LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS + ): + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: @@ -148,8 +156,12 @@ async def get_key_type( checker = WalletInvoiceKeyChecker(api_key=token) await checker.__call__(r) wallet = WalletTypeInfo(1, checker.wallet) - if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( + LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS + ): + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: diff --git a/lnbits/extensions/discordbot/models.py b/lnbits/extensions/discordbot/models.py index 4be367f8..985eb096 100644 --- a/lnbits/extensions/discordbot/models.py +++ b/lnbits/extensions/discordbot/models.py @@ -11,6 +11,7 @@ class CreateUserData(BaseModel): admin_id: str = Query(...) discord_id: str = Query("") + class CreateUserWallet(BaseModel): user_id: str = Query(...) wallet_name: str = Query(...) @@ -23,6 +24,7 @@ class Users(BaseModel): admin: str discord_id: str + class Wallets(BaseModel): id: str admin: str diff --git a/lnbits/extensions/discordbot/views_api.py b/lnbits/extensions/discordbot/views_api.py index 64d1df1a..6f213a89 100644 --- a/lnbits/extensions/discordbot/views_api.py +++ b/lnbits/extensions/discordbot/views_api.py @@ -109,9 +109,7 @@ async def api_discordbot_wallet_transactions( async def api_discordbot_users_wallets( user_id, wallet: WalletTypeInfo = Depends(get_key_type) ): - return [ - s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id) - ] + return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)] @discordbot_ext.delete("/api/v1/wallets/{wallet_id}") diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py index 29a0f0b6..96cc6428 100644 --- a/lnbits/extensions/example/__init__.py +++ b/lnbits/extensions/example/__init__.py @@ -5,10 +5,7 @@ from lnbits.helpers import template_renderer db = Database("ext_example") -example_ext: APIRouter = APIRouter( - prefix="/example", - tags=["example"] -) +example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"]) def example_renderer(): diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py index 0347a06f..bfeb7517 100644 --- a/lnbits/extensions/example/models.py +++ b/lnbits/extensions/example/models.py @@ -3,4 +3,3 @@ # class Example(BaseModel): # id: str # wallet: str - diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py index f144fe76..5b702717 100644 --- a/lnbits/extensions/example/views_api.py +++ b/lnbits/extensions/example/views_api.py @@ -10,6 +10,7 @@ from . import example_ext # add your endpoints here + @example_ext.get("/api/v1/tools") async def api_example(): """Try to add descriptions for others.""" diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py index caaac7e5..d160daee 100644 --- a/lnbits/extensions/jukebox/crud.py +++ b/lnbits/extensions/jukebox/crud.py @@ -41,7 +41,7 @@ async def update_jukebox( q = ", ".join([f"{field[0]} = ?" for field in data]) items = [f"{field[1]}" for field in data] items.append(juke_id) - q = q.replace("user", '"user"', 1) # hack to make user be "user"! + q = q.replace("user", '"user"', 1) # hack to make user be "user"! await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) return Jukebox(**row) if row else None diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py index 3ba8cbf2..1f3723a7 100644 --- a/lnbits/extensions/jukebox/views_api.py +++ b/lnbits/extensions/jukebox/views_api.py @@ -455,5 +455,6 @@ async def api_get_jukebox_currently( ) except: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong, or no song is playing yet" + status_code=HTTPStatus.NOT_FOUND, + detail="Something went wrong, or no song is playing yet", ) diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py index 4f1bd1c5..ef035431 100644 --- a/lnbits/extensions/livestream/views.py +++ b/lnbits/extensions/livestream/views.py @@ -1,4 +1,5 @@ from http import HTTPStatus + # from mmap import MAP_DENYWRITE from fastapi.param_functions import Depends diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index db2c19cc..6b56cf55 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"): key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) counter = struct.pack(">Q", counter) mac = hmac.new(key, counter, digest).digest() - offset = mac[-1] & 0x0f - binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF return str(binary)[-digits:].zfill(digits) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py index 3d1c2575..8052c63b 100644 --- a/lnbits/extensions/paywall/views_api.py +++ b/lnbits/extensions/paywall/views_api.py @@ -54,8 +54,7 @@ async def api_paywall_delete( @paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}") async def api_paywall_create_invoice( - data: CreatePaywallInvoice, - paywall_id: str = Query(None) + data: CreatePaywallInvoice, paywall_id: str = Query(None) ): paywall = await get_paywall(paywall_id) if data.amount < paywall.amount: @@ -78,7 +77,9 @@ async def api_paywall_create_invoice( @paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}") -async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)): +async def api_paywal_check_invoice( + data: CheckPaywallInvoice, paywall_id: str = Query(None) +): paywall = await get_paywall(paywall_id) payment_hash = data.payment_hash if not paywall: diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py index 0a678d8b..bb2998ee 100644 --- a/lnbits/extensions/streamalerts/views_api.py +++ b/lnbits/extensions/streamalerts/views_api.py @@ -123,7 +123,7 @@ async def api_create_donation(data: CreateDonation, request: Request): completelinktext="Back to Stream!", webhook=webhook_base + "/streamalerts/api/v1/postdonation", description=description, - **charge_details + **charge_details, ) charge = await create_charge(user=charge_details["user"], data=create_charge_data) await create_donation( diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 801fa62f..859d8aaa 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -30,7 +30,9 @@ async def api_lnurl_response(request: Request, unique_hash): ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) withdrawResponse = { "tag": "withdrawRequest", @@ -48,7 +50,11 @@ async def api_lnurl_response(request: Request, unique_hash): @withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") async def api_lnurl_callback( - unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...), id_unique_hash=None + unique_hash, + request: Request, + k1: str = Query(...), + pr: str = Query(...), + id_unique_hash=None, ): link = await get_withdraw_link_by_hash(unique_hash) now = int(datetime.now().timestamp()) @@ -58,7 +64,9 @@ async def api_lnurl_callback( ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) if link.k1 != k1: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.") @@ -81,7 +89,7 @@ async def api_lnurl_callback( if id_unique_hash == shortuuid.uuid(name=tohash): found = True useslist.pop(ind) - usescsv = ','.join(useslist) + usescsv = ",".join(useslist) if not found: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." @@ -134,7 +142,9 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) useslist = link.usescsv.split(",") found = False diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 81aeef98..1f059a4b 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -103,6 +103,7 @@ async def print_qr(request: Request, link_id): "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} ) + @withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse) async def print_qr(request: Request, link_id): link = await get_withdraw_link(link_id) @@ -135,4 +136,4 @@ async def print_qr(request: Request, link_id): return withdraw_renderer().TemplateResponse( "withdraw/csv.html", {"request": request, "link": linked, "unique": True} - ) \ No newline at end of file + ) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index c48a26b7..027b2925 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -72,10 +72,8 @@ async def api_link_create_or_update( wallet: WalletTypeInfo = Depends(require_admin_key), ): if data.uses > 250: - raise HTTPException( - detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST - ) - + raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) + if data.min_withdrawable < 1: raise HTTPException( detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST diff --git a/lnbits/helpers.py b/lnbits/helpers.py index cb6f8ee7..4ccfccea 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -26,7 +26,9 @@ class Extension(NamedTuple): class ExtensionManager: def __init__(self): self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS - self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS] + self._admin_only: List[str] = [ + x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS + ] self._extension_folders: List[str] = [ x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions")) ][0] diff --git a/lnbits/settings.py b/lnbits/settings.py index 9ccd9e4e..e3eb57e3 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -29,7 +29,9 @@ LNBITS_ALLOWED_USERS: List[str] = env.list( "LNBITS_ALLOWED_USERS", default=[], subcast=str ) LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str) -LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str) +LNBITS_ADMIN_EXTENSIONS: List[str] = env.list( + "LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str +) LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str ) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 04fd498f..aa7ddc39 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -29,6 +29,7 @@ class EclairError(Exception): class UnknownError(Exception): pass + class EclairWallet(Wallet): def __init__(self): url = getenv("ECLAIR_URL") @@ -41,13 +42,10 @@ class EclairWallet(Wallet): auth = str(encodedAuth, "utf-8") self.auth = {"Authorization": f"Basic {auth}"} - async def status(self) -> StatusResponse: async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/usablebalances", - headers=self.auth, - timeout=40 + f"{self.url}/usablebalances", headers=self.auth, timeout=40 ) try: data = r.json() @@ -55,7 +53,7 @@ class EclairWallet(Wallet): return StatusResponse( f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0 ) - + if r.is_error: return StatusResponse(data["error"], 0) @@ -76,10 +74,7 @@ class EclairWallet(Wallet): async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/createinvoice", - headers=self.auth, - data=data, - timeout=40 + f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40 ) if r.is_error: @@ -95,7 +90,6 @@ class EclairWallet(Wallet): data = r.json() return InvoiceResponse(True, data["paymentHash"], data["serialized"], None) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async with httpx.AsyncClient() as client: r = await client.post( @@ -113,10 +107,9 @@ class EclairWallet(Wallet): error_message = r.text pass return PaymentResponse(False, None, 0, None, error_message) - + data = r.json() - checking_id = data["paymentHash"] preimage = data["paymentPreimage"] @@ -135,22 +128,22 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(True, checking_id, 0, preimage, error_message) ## ?? is this ok ?? + return PaymentResponse( + True, checking_id, 0, preimage, error_message + ) ## ?? is this ok ?? data = r.json() fees = [i["status"] for i in data] fee_msat = sum([i["feesPaid"] for i in fees]) - - return PaymentResponse(True, checking_id, fee_msat, preimage, None) - + return PaymentResponse(True, checking_id, fee_msat, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( f"{self.url}/getreceivedinfo", headers=self.auth, - data={"paymentHash": checking_id} + data={"paymentHash": checking_id}, ) data = r.json() @@ -160,31 +153,33 @@ class EclairWallet(Wallet): if data["status"]["type"] != "received": return PaymentStatus(False) - return PaymentStatus(True) + return PaymentStatus(True) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( url=f"{self.url}/getsentinfo", headers=self.auth, - data={"paymentHash": checking_id} - + data={"paymentHash": checking_id}, ) data = r.json()[0] if r.is_error: return PaymentStatus(None) - + if data["status"]["type"] != "sent": return PaymentStatus(False) return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - + try: - async with connect(self.ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + async with connect( + self.ws_url, + extra_headers=[("Authorization", self.auth["Authorization"])], + ) as ws: while True: message = await ws.recv() message = json.loads(message) @@ -192,8 +187,13 @@ class EclairWallet(Wallet): if message and message["type"] == "payment-received": yield message["paymentHash"] - except (OSError, ConnectionClosedOK, ConnectionClosedError, ConnectionClosed) as ose: - print('OSE', ose) + except ( + OSError, + ConnectionClosedOK, + ConnectionClosedError, + ConnectionClosed, + ) as ose: + print("OSE", ose) pass print("lost connection to eclair's websocket, retrying in 5 seconds") diff --git a/lnbits/wallets/macaroon/__init__.py b/lnbits/wallets/macaroon/__init__.py index b7cadcfe..4967c100 100644 --- a/lnbits/wallets/macaroon/__init__.py +++ b/lnbits/wallets/macaroon/__init__.py @@ -1 +1 @@ -from .macaroon import load_macaroon, AESCipher \ No newline at end of file +from .macaroon import load_macaroon, AESCipher diff --git a/lnbits/wallets/macaroon/macaroon.py b/lnbits/wallets/macaroon/macaroon.py index dd6ff636..3548e9e2 100644 --- a/lnbits/wallets/macaroon/macaroon.py +++ b/lnbits/wallets/macaroon/macaroon.py @@ -5,10 +5,11 @@ from hashlib import md5 import getpass BLOCK_SIZE = 16 -import getpass +import getpass + def load_macaroon(macaroon: str) -> str: - """Returns hex version of a macaroon encoded in base64 or the file path. + """Returns hex version of a macaroon encoded in base64 or the file path. :param macaroon: Macaroon encoded in base64 or file path. :type macaroon: str @@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str: pass return macaroon + class AESCipher(object): """This class is compatible with crypto-js/aes.js @@ -39,6 +41,7 @@ class AESCipher(object): AES.decrypt(encrypted, password).toString(Utf8); """ + def __init__(self, key=None, description=""): self.key = key self.description = description + " " @@ -47,7 +50,6 @@ class AESCipher(object): length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) return data + (chr(length) * length).encode() - def unpad(self, data): return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))] @@ -70,8 +72,7 @@ class AESCipher(object): return final_key[:output] def decrypt(self, encrypted: str) -> str: - """Decrypts a string using AES-256-CBC. - """ + """Decrypts a string using AES-256-CBC.""" passphrase = self.passphrase encrypted = base64.b64decode(encrypted) assert encrypted[0:8] == b"Salted__" @@ -92,7 +93,10 @@ class AESCipher(object): key = key_iv[:32] iv = key_iv[32:] aes = AES.new(key, AES.MODE_CBC, iv) - return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode() + return base64.b64encode( + b"Salted__" + salt + aes.encrypt(self.pad(message)) + ).decode() + # if this file is executed directly, ask for a macaroon and encrypt it if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py index 127233c1..27ba9137 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,10 @@ def app(): loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() + @pytest.fixture async def client(app): - client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}') + client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}") # yield and pass the client to the test yield client # close the async client after the test has finished diff --git a/tests/core/views/test_generic.py b/tests/core/views/test_generic.py index 4917cde4..5d3db0fb 100644 --- a/tests/core/views/test_generic.py +++ b/tests/core/views/test_generic.py @@ -1,6 +1,7 @@ import pytest from tests.conftest import client + @pytest.mark.asyncio async def test_core_views_generic(client): response = await client.get("/") diff --git a/tests/extensions/bleskomat/conftest.py b/tests/extensions/bleskomat/conftest.py index 924998a7..265d3be0 100644 --- a/tests/extensions/bleskomat/conftest.py +++ b/tests/extensions/bleskomat/conftest.py @@ -4,16 +4,22 @@ import secrets from lnbits.core.crud import create_account, create_wallet from lnbits.extensions.bleskomat.crud import create_bleskomat, create_bleskomat_lnurl from lnbits.extensions.bleskomat.models import CreateBleskomat -from lnbits.extensions.bleskomat.helpers import generate_bleskomat_lnurl_secret, generate_bleskomat_lnurl_signature, prepare_lnurl_params, query_to_signing_payload +from lnbits.extensions.bleskomat.helpers import ( + generate_bleskomat_lnurl_secret, + generate_bleskomat_lnurl_signature, + prepare_lnurl_params, + query_to_signing_payload, +) from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers exchange_rate_providers["dummy"] = { "name": "dummy", "domain": None, "api_url": None, - "getter": lambda data, replacements: str(1e8),# 1 BTC = 100000000 sats + "getter": lambda data, replacements: str(1e8), # 1 BTC = 100000000 sats } + @pytest.fixture async def bleskomat(): user = await create_account() @@ -22,11 +28,12 @@ async def bleskomat(): name="Test Bleskomat", fiat_currency="EUR", exchange_rate_provider="dummy", - fee="0" + fee="0", ) bleskomat = await create_bleskomat(data=data, wallet_id=wallet.id) return bleskomat + @pytest.fixture async def lnurl(bleskomat): query = { @@ -43,7 +50,7 @@ async def lnurl(bleskomat): signature = generate_bleskomat_lnurl_signature( payload=payload, api_key_secret=bleskomat.api_key_secret, - api_key_encoding=bleskomat.api_key_encoding + api_key_encoding=bleskomat.api_key_encoding, ) secret = generate_bleskomat_lnurl_secret(bleskomat.api_key_id, signature) params = json.JSONEncoder().encode(params) diff --git a/tests/helpers.py b/tests/helpers.py index 1687e25d..3774f6fc 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,6 +2,7 @@ import hashlib import secrets from lnbits.core.crud import create_payment + async def credit_wallet(wallet_id: str, amount: int): preimage = secrets.token_hex(32) m = hashlib.sha256() @@ -14,6 +15,6 @@ async def credit_wallet(wallet_id: str, amount: int): checking_id=payment_hash, preimage=preimage, memo="", - amount=amount,# msat - pending=False,# not pending, so it will increase the wallet's balance + amount=amount, # msat + pending=False, # not pending, so it will increase the wallet's balance ) From 895d9d2e0aa60f075b768061b71bdd712ae9e21b Mon Sep 17 00:00:00 2001 From: Tomas Bezouska Date: Wed, 1 Jun 2022 15:24:17 +0200 Subject: [PATCH 032/209] Extension: LNURLw webhook_url (#610) * LNURLw web_hook * crlf -> lf * Fix typo * LNURLw webhook api doc --- lnbits/extensions/withdraw/crud.py | 6 +++-- lnbits/extensions/withdraw/lnurl.py | 25 +++++++++++++++++-- lnbits/extensions/withdraw/migrations.py | 6 +++++ lnbits/extensions/withdraw/models.py | 2 ++ lnbits/extensions/withdraw/static/js/index.js | 3 ++- .../templates/withdraw/_api_docs.html | 5 ++-- .../withdraw/templates/withdraw/index.html | 14 +++++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index 18a057f3..9d55d245 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/crud.py @@ -25,9 +25,10 @@ async def create_withdraw_link( unique_hash, k1, open_time, - usescsv + usescsv, + webhook_url ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( link_id, @@ -42,6 +43,7 @@ async def create_withdraw_link( urlsafe_short_hash(), int(datetime.now().timestamp()) + data.wait_time, usescsv, + data.webhook_url ), ) link = await get_withdraw_link(link_id, 0) diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 801fa62f..603e7dad 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,4 +1,7 @@ import json +import traceback +import httpx + from datetime import datetime from http import HTTPStatus @@ -103,17 +106,35 @@ async def api_lnurl_callback( await update_withdraw_link(link.id, **changes) payment_request = pr - - await pay_invoice( + + payment_hash = await pay_invoice( wallet_id=link.wallet, payment_request=payment_request, max_sat=link.max_withdrawable, extra={"tag": "withdraw"}, ) + + if link.webhook_url: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + link.webhook_url, + json={ + "payment_hash": payment_hash, + "payment_request": payment_request, + "lnurlw": link.id, + }, + timeout=40, + ) + except Exception as exc: + # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid + print("Caught exception when dispatching webhook url:", exc) + return {"status": "OK"} except Exception as e: await update_withdraw_link(link.id, **changesback) + print(traceback.format_exc()) return {"status": "ERROR", "reason": "Link not working"} diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py index 1a13aa6d..5c527e79 100644 --- a/lnbits/extensions/withdraw/migrations.py +++ b/lnbits/extensions/withdraw/migrations.py @@ -108,3 +108,9 @@ async def m003_make_hash_check(db): ); """ ) + +async def m004_webhook_url(db): + """ + Adds webhook_url + """ + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;") \ No newline at end of file diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index a03c7db8..c3ca7c45 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel): uses: int = Query(..., ge=1) wait_time: int = Query(..., ge=1) is_unique: bool + webhook_url: str = Query(None) class WithdrawLink(BaseModel): @@ -32,6 +33,7 @@ class WithdrawLink(BaseModel): used: int = Query(0) usescsv: str = Query(None) number: int = Query(0) + webhook_url: str = Query(None) @property def is_spent(self) -> bool: diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js index 91ff6446..e54005c6 100644 --- a/lnbits/extensions/withdraw/static/js/index.js +++ b/lnbits/extensions/withdraw/static/js/index.js @@ -179,7 +179,8 @@ new Vue({ 'max_withdrawable', 'uses', 'wait_time', - 'is_unique' + 'is_unique', + 'webhook_url' ) ) .then(function (response) { diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index c1172bcd..76068fcb 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -70,7 +70,8 @@ {"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}
Returns 201 CREATED (application/json) @@ -81,7 +82,7 @@ >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}' -H + "wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H "Content-type: application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 0ce8507b..f6ea220d 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -29,6 +29,7 @@ {{ col.label }} + @@ -145,11 +145,13 @@ icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" @click="ticketCard(props)" - > Click to show ticket + > Click to show ticket - {{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }} + {{ col.label == "Ticket" ? col.value.length > 20 ? + `${col.value.substring(0, 20)}...` : col.value : col.value }} @@ -410,7 +412,7 @@ }) }) }, - ticketCard(ticket){ + ticketCard(ticket) { this.ticketDialog.show = true let {date, email, ltext, name} = ticket.row this.ticketDialog.data = { @@ -469,7 +471,7 @@ }, updateformDialog: function (formId) { var link = _.findWhere(this.forms, {id: formId}) - console.log("LINK", link) + console.log('LINK', link) this.formDialog.data.id = link.id this.formDialog.data.wallet = link.wallet diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html index e255d4a0..d5b4b5b8 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html @@ -39,8 +39,8 @@ [<lnurldevice_object>, ...]
Curl example
curl -X POST {{ request.base_url }}lnurldevice/api/v1/lnurlpos -d '{"title": - <string>, "message":<string>, "currency": + >curl -X POST {{ request.base_url }}lnurldevice/api/v1/lnurlpos -d + '{"title": <string>, "message":<string>, "currency": <integer>}' -H "Content-type: application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" @@ -104,8 +104,8 @@
Curl example
curl -X GET {{ request.base_url - }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: + {{ user.wallets[0].inkey }}" @@ -159,8 +159,8 @@
Curl example
curl -X DELETE {{ request.base_url - }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index b51e2556..24d19484 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -480,12 +480,11 @@ updatedData ) .then(function (response) { - self.lnurldeviceLinks = _.reject( - self.lnurldeviceLinks, - function (obj) { - return obj.id === updatedData.id - } - ) + self.lnurldeviceLinks = _.reject(self.lnurldeviceLinks, function ( + obj + ) { + return obj.id === updatedData.id + }) self.lnurldeviceLinks.push(maplnurldevice(response.data)) self.formDialoglnurldevice.show = false self.clearFormDialoglnurldevice() diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html index bf920558..200865fb 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -17,8 +17,8 @@ [<pay_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key: + {{ user.wallets[0].inkey }}" @@ -39,8 +39,8 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -68,11 +68,11 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d '{"description": - <string>, "amount": <integer>, "max": <integer>, - "min": <integer>, "comment_chars": <integer>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d + '{"description": <string>, "amount": <integer>, "max": + <integer>, "min": <integer>, "comment_chars": + <integer>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" @@ -99,8 +99,8 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -d - '{"description": <string>, "amount": <integer>}' -H + >curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -d '{"description": <string>, "amount": <integer>}' -H "Content-type: application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -126,8 +126,9 @@
Curl example
curl -X DELETE {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -H - "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html index bd68b629..a472d549 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -127,7 +127,13 @@ - + DELETE diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 664cda0f..2c8fe672 100644 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -17,8 +17,8 @@ [<paywall_object>, ...]
Curl example
curl -X GET {{ request.base_url }}paywall/api/v1/paywalls -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}paywall/api/v1/paywalls -H + "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -48,11 +48,11 @@ >
Curl example
curl -X POST {{ request.base_url }}paywall/api/v1/paywalls -d '{"url": - <string>, "memo": <string>, "description": <string>, - "amount": <integer>, "remembers": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}paywall/api/v1/paywalls -d + '{"url": <string>, "memo": <string>, "description": + <string>, "amount": <integer>, "remembers": + <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html index 644bcdfe..a80fd37a 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html +++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html @@ -17,8 +17,8 @@ [<satsdice_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satsdice/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}satsdice/api/v1/links -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -44,8 +44,9 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -73,8 +74,8 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}satsdice/api/v1/links -d '{"title": - <string>, "min_satsdiceable": <integer>, + >curl -X POST {{ request.base_url }}satsdice/api/v1/links -d + '{"title": <string>, "min_satsdiceable": <integer>, "max_satsdiceable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -109,8 +110,9 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> -d - '{"title": <string>, "min_satsdiceable": <integer>, + >curl -X PUT {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -d '{"title": + <string>, "min_satsdiceable": <integer>, "max_satsdiceable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -137,8 +139,9 @@
Curl example
curl -X DELETE {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> - -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -165,8 +168,8 @@
Curl example
curl -X GET {{ request.base_url - }}satsdice/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}satsdice/api/v1/links/<the_hash>/<lnurl_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index 89380de0..77451ae5 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -60,12 +60,13 @@ [<charge_object>, ...]
Curl example
curl -X POST {{ request.base_url }}satspay/api/v1/charge/<charge_id> - -d '{"onchainwallet": <string, watchonly_wallet_id>, - "description": <string>, "webhook":<string>, "time": - <integer>, "amount": <integer>, "lnbitswallet": - <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url + }}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet": + <string, watchonly_wallet_id>, "description": <string>, + "webhook":<string>, "time": <integer>, "amount": + <integer>, "lnbitswallet": <string, lnbits_wallet_id>}' + -H "Content-type: application/json" -H "X-Api-Key: + {{user.wallets[0].adminkey }}" @@ -89,8 +90,9 @@ [<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satspay/api/v1/charge/<charge_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -112,8 +114,8 @@ [<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satspay/api/v1/charges -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}satspay/api/v1/charges -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -145,7 +147,8 @@
diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js index dea469e5..d9750bef 100644 --- a/lnbits/extensions/splitpayments/static/js/index.js +++ b/lnbits/extensions/splitpayments/static/js/index.js @@ -119,7 +119,7 @@ new Vue({ '/splitpayments/api/v1/targets', this.selectedWallet.adminkey, { - "targets": this.targets + targets: this.targets .filter(isTargetComplete) .map(({wallet, percent, alias}) => ({wallet, percent, alias})) } diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html index 7a8a6d07..4cf7190c 100644 --- a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html +++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html @@ -52,8 +52,8 @@ >
Curl example
curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html index 42788bad..95ba6e06 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html +++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html @@ -4,10 +4,9 @@ Tip Jar: Receive tips with messages!

- Your personal Bitcoin tip page, which supports - lightning and on-chain payments. - Notifications, including a donation message, - can be sent via webhook. + Your personal Bitcoin tip page, which supports lightning and on-chain + payments. Notifications, including a donation message, can be sent via + webhook. Created by, Fitti diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html index dda49842..19fca6e4 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/index.html +++ b/lnbits/extensions/tipjar/templates/tipjar/index.html @@ -322,11 +322,7 @@ var self = this LNbits.api - .request( - 'GET', - '/tipjar/api/v1/tips', - this.g.user.wallets[0].inkey - ) + .request('GET', '/tipjar/api/v1/tips', this.g.user.wallets[0].inkey) .then(function (response) { self.tips = response.data.map(function (obj) { return mapTipJar(obj) diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html index 42160cce..8930d990 100644 --- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html +++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html @@ -69,8 +69,8 @@

Curl example
curl -X DELETE {{ request.base_url }}tpos/api/v1/tposs/<tpos_id> -H - "X-Api-Key: <admin_key>" + >curl -X DELETE {{ request.base_url + }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>" diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html index af3b0573..76f33000 100644 --- a/lnbits/extensions/tpos/templates/tpos/index.html +++ b/lnbits/extensions/tpos/templates/tpos/index.html @@ -54,7 +54,8 @@ >
- {{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") : col.value) }} + {{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") + : col.value) }} parseInt(str))) : JSON.stringify([])), - tip_wallet: this.formDialog.data.tip_wallet || "", + tip_options: this.formDialog.data.tip_options + ? JSON.stringify( + this.formDialog.data.tip_options.map(str => parseInt(str)) + ) + : JSON.stringify([]), + tip_wallet: this.formDialog.data.tip_wallet || '' } var self = this diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index e4ea1499..70b84f9b 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,7 +1,5 @@ -{% extends "public.html" %} -{% block toolbar_title %} -{{ tpos.name }} +{% extends "public.html" %} {% block toolbar_title %} {{ tpos.name }} -{% endblock %} -{% block footer %}{% endblock %} {% block page_container %} +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} @@ -180,27 +177,24 @@ - +
- Would you like to leave a tip? + Would you like to leave a tip?
- {% raw %}{{ tip }}{% endraw %}% + {% raw %}{{ tip }}{% endraw %}%

No, thanks

@@ -265,7 +259,7 @@ } .keypad .btn-confirm { - grid-area: 1 / 4 / 5 / 4; + grid-area: 1 / 4 / 5 / 4; } {% endblock %} {% block scripts %} @@ -282,7 +276,7 @@ tip_options: JSON.parse('{{ tpos.tip_options }}'), exchangeRate: null, stack: [], - tipAmount: 0.00, + tipAmount: 0.0, invoiceDialog: { show: false, data: null, @@ -290,7 +284,7 @@ paymentChecker: null }, tipDialog: { - show: false, + show: false }, urlDialog: { show: false @@ -324,7 +318,7 @@ methods: { closeInvoiceDialog: function () { this.stack = [] - this.tipAmount = 0.00 + this.tipAmount = 0.0 var dialog = this.invoiceDialog setTimeout(function () { clearInterval(dialog.paymentChecker) @@ -334,8 +328,10 @@ processTipSelection: function (selectedTipOption) { this.tipDialog.show = false - if(selectedTipOption) { - const tipAmount = parseFloat(parseFloat((selectedTipOption / 100) * this.amount)) + if (selectedTipOption) { + const tipAmount = parseFloat( + parseFloat((selectedTipOption / 100) * this.amount) + ) const subtotal = parseFloat(this.amount) const grandTotal = parseFloat((tipAmount + subtotal).toFixed(2)) const totalString = grandTotal.toFixed(2).toString() @@ -344,7 +340,7 @@ for (var i = 0; i < totalString.length; i++) { const char = totalString[i] - if(char !== ".") { + if (char !== '.') { this.stack.push(char) } } @@ -354,14 +350,14 @@ this.showInvoice() }, - submitForm: function() { - if(this.tip_options.length) { + submitForm: function () { + if (this.tip_options.length) { this.showTipModal() } else { this.showInvoice() } }, - showTipModal: function() { + showTipModal: function () { this.tipDialog.show = true }, showInvoice: function () { @@ -372,7 +368,7 @@ .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, { params: { amount: this.sat, - tipAmount: this.tipAmountSat, + tipAmount: this.tipAmountSat } }) .then(function (response) { diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html index bff66383..b421186a 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -37,8 +37,8 @@ [<wallets_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -66,8 +66,9 @@ [<wallet_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/wallet/<wallet_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -89,9 +90,10 @@ [<wallet_object>, ...]
Curl example
curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d '{"title": - <string>, "masterpub": <string>}' -H "Content-type: - application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d + '{"title": <string>, "masterpub": <string>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -173,8 +175,9 @@ [<address_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/address/<wallet_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}watchonly/api/v1/address/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -202,8 +205,8 @@ [<mempool_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -233,9 +236,9 @@ [<mempool_object>, ...]
Curl example
curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d '{"endpoint": - <string>}' -H "Content-type: application/json" -H "X-Api-Key: - {{ user.wallets[0].adminkey }}" + >curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d + '{"endpoint": <string>}' -H "Content-type: application/json" + -H "X-Api-Key: {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index 76068fcb..095aad3a 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -22,8 +22,8 @@ [<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}withdraw/api/v1/links -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -49,8 +49,9 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -79,12 +80,12 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title": - <string>, "min_withdrawable": <integer>, + >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d + '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + "wait_time": <integer>, "is_unique": <boolean>, + "webhook_url": <string>}' -H "Content-type: application/json" -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -115,8 +116,9 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -d - '{"title": <string>, "min_withdrawable": <integer>, + >curl -X PUT {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -d '{"title": + <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -143,8 +145,9 @@
Curl example
curl -X DELETE {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> - -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -171,8 +174,8 @@
Curl example
curl -X GET {{ request.base_url - }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/csv.html b/lnbits/extensions/withdraw/templates/withdraw/csv.html index d8f8c4d0..62902905 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/csv.html +++ b/lnbits/extensions/withdraw/templates/withdraw/csv.html @@ -1,10 +1,12 @@ -{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %} +{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes +in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor +%} {% endblock %} {% block scripts %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index f6ea220d..6d3ab374 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -1,28 +1,40 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }} +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
-
- - - Quick vouchers - Advanced withdraw link(s) - - +
+ + + Quick vouchers + Advanced withdraw link(s) + + - - -
-
-
Withdraw links
-
-
- Export to CSV -
-
- - {% raw %} -