diff --git a/lnbits/Pipfile b/lnbits/Pipfile new file mode 100644 index 000000000..3517399db --- /dev/null +++ b/lnbits/Pipfile @@ -0,0 +1,62 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +aiofiles = "==0.8.0" +anyio = "==3.6.1" +asyncio = "==3.4.3" +attrs = "==21.4.0" +bech32 = "==1.2.0" +bitstring = "==3.1.9" +cerberus = "==1.3.4" +certifi = "==2022.6.15" +cffi = "==1.15.0" +click = "==8.1.3" +ecdsa = "==0.18.0" +embit = "==0.5.0" +environs = "==9.5.0" +fastapi = "==0.79.0" +h11 = "==0.12.0" +httpcore = "==0.15.0" +httptools = "==0.4.0" +httpx = "==0.23.0" +idna = "==3.3" +jinja2 = "==3.0.1" +lnurl = "==0.3.6" +loguru = "==0.6.0" +markupsafe = "==2.1.1" +marshmallow = "==3.17.0" +outcome = "==1.2.0" +psycopg2-binary = "==2.9.3" +pycparser = "==2.21" +pycryptodomex = "==3.15.0" +pydantic = "==1.9.1" +pyngrok = "==5.1.0" +pyparsing = "==3.0.9" +pypng = "==0.20220715.0" +pyqrcode = "==1.2.1" +pyscss = "==1.4.0" +python-dotenv = "==0.20.0" +pyyaml = "==6.0" +represent = "==1.6.0.post0" +rfc3986 = "==1.5.0" +secp256k1 = "==0.14.0" +shortuuid = "==1.0.9" +six = "==1.16.0" +sniffio = "==1.2.0" +sqlalchemy-aio = "==0.17.0" +sqlalchemy = "==1.3.23" +sse-starlette = "==0.10.3" +starlette = "==0.19.1" +typing-extensions = "==4.3.0" +uvicorn = "==0.18.2" +uvloop = "==0.16.0" +watchfiles = "==0.16.0" +websockets = "==10.3" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/lnbits/extensions/gerty/config.json b/lnbits/extensions/gerty/config.json index 158ac52a0..a36437beb 100644 --- a/lnbits/extensions/gerty/config.json +++ b/lnbits/extensions/gerty/config.json @@ -2,5 +2,5 @@ "name": "Gerty", "short_description": "Desktop bitcoin Assistant", "icon": "sentiment_satisfied", - "contributors": ["arcbtc"] + "contributors": ["arcbtc", "blackcoffeebtc"] } diff --git a/lnbits/extensions/gerty/crud.py b/lnbits/extensions/gerty/crud.py index 9eeb1a4af..a472ef37f 100644 --- a/lnbits/extensions/gerty/crud.py +++ b/lnbits/extensions/gerty/crud.py @@ -10,8 +10,16 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty: gerty_id = urlsafe_short_hash() await db.execute( """ - INSERT INTO gerty.gertys (id, name, wallet, lnbits_wallets, mempool_endpoint, sats_quote, exchange, onchain_stats, ln_stats) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO gerty.gertys ( + id, + name, + wallet, + lnbits_wallets, + mempool_endpoint, + exchange, + display_preferences + ) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( gerty_id, @@ -19,10 +27,8 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty: data.wallet, data.lnbits_wallets, data.mempool_endpoint, - data.sats_quote, data.exchange, - data.onchain_stats, - data.ln_stats, + data.display_preferences ), ) diff --git a/lnbits/extensions/gerty/migrations.py b/lnbits/extensions/gerty/migrations.py index d3bba79cd..459fc8807 100644 --- a/lnbits/extensions/gerty/migrations.py +++ b/lnbits/extensions/gerty/migrations.py @@ -1,19 +1,18 @@ async def m001_initial(db): """ - Initial gertys table. + Initial Gertys table. """ await db.execute( """ CREATE TABLE gerty.gertys ( id TEXT PRIMARY KEY, + refresh_time INT, name TEXT NOT NULL, wallet TEXT NOT NULL, lnbits_wallets TEXT, mempool_endpoint TEXT, - sats_quote BOOL, exchange TEXT, - onchain_stats BOOL, - ln_stats BOOL + display_preferences TEXT ); """ ) \ No newline at end of file diff --git a/lnbits/extensions/gerty/models.py b/lnbits/extensions/gerty/models.py index c3590f032..fc7a33774 100644 --- a/lnbits/extensions/gerty/models.py +++ b/lnbits/extensions/gerty/models.py @@ -4,18 +4,15 @@ from typing import Optional from fastapi import Query from pydantic import BaseModel - - class Gerty(BaseModel): id: str = Query(None) name: str wallet: str + refresh_time: int = Query(None) lnbits_wallets: str = Query(None) # Wallets to keep an eye on, {"wallet-id": "wallet-read-key, etc"} mempool_endpoint: str = Query(None) # Mempool endpoint to use - sats_quote: bool = Query(False) # Fetch Satoshi quotes exchange: str = Query(None) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats - onchain_stats: bool = Query(False) # Onchain stats - ln_stats: bool = Query(False) # ln Sats + display_preferences: str = Query(None) @classmethod def from_row(cls, row: Row) -> "Gerty": diff --git a/lnbits/extensions/gerty/static/pieter_wuille.json b/lnbits/extensions/gerty/static/pieter_wuille.json new file mode 100644 index 000000000..986150ed2 --- /dev/null +++ b/lnbits/extensions/gerty/static/pieter_wuille.json @@ -0,0 +1,14 @@ +{ + "facts": [ + "When a woman asked Pieter Wuille to talk dirty to her, he described the OpenSSL DER implementation.", + "Pieter Wuille recently visited an event horizon and escaped with a cryptographic proof.", + "Pieter Wuille's PhD thesis defence in full: \"Pieter Wuille, thank you\".", + "Pieter Wuille is an acronym for Programmatic Intelligent Encrypted Telemetric Encapsulated Recursive Witness Upscaling Integrated Load-Balancing Logical Entity.", + "Dan Bernstein only trusts one source of random numbers: Pieter Wuille.", + "Putting Pieter Wuille in the title of an r/Bitcoin submission gets more upvotes than the same post from Pieter Wuille himself.", + "Pieter Wuille won the underhanded crypto contest but his entry was so underhanded nobody even knows he entered.", + "Greg Maxwell is a bot created by Pieter Wuille to argue on reddit while he can get code done.", + "Pieter Wuille doesn't need the public key to calculate the corresponding private key.", + "When the Wikipedia servers corrupted all data including backups, Pieter Wuille had to stay awake all night to retype it." + ] +} \ No newline at end of file diff --git a/lnbits/extensions/gerty/templates/gerty/index.html b/lnbits/extensions/gerty/templates/gerty/index.html index 7ba0e0297..a59bf15d8 100644 --- a/lnbits/extensions/gerty/templates/gerty/index.html +++ b/lnbits/extensions/gerty/templates/gerty/index.html @@ -1,575 +1,793 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Gerty - - - - -
-
-
Gerty
-
-
- Export to CSV -
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block page %} +
+
+ + + New Gerty + + + + + +
+
+
Gerty
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
- - {% raw %} - - - {% endraw %} - - - -
- -
- - -
{{SITE_TITLE}} Gerty extension
-
- - - - {% include "gerty/_api_docs.html" %} - - -
-
- - - - - - - Hit enter to add values - - Used for getting onchain/ln stats - Gets random quotes from satoshi - Gets Onchain Statistics - Gets Lightning-Network Statistics -
- Create Gerty - Update Gerty - Cancel +
+ + +
{{ SITE_TITLE }} Gerty extension
+
+ + + + {% include "gerty/_api_docs.html" %} + + +
- - - -
+ + + + + + + + Hit enter to add values + + + + Used for getting onchain/ln stats + + + + The amount of time in seconds between screen updates + + +

Use the toggles below to control what your Gerty will display

+ + + + + + + + Toggle all + +
+ + Displays random quotes from Satoshi + + + Show accurate facts about Pieter Wuille + + +
+ + + Toggle all + +
+ + + + + + + +
+ + + Toggle all + +
+ + + + +
+ + + + Toggle all + +
+ + + +
+ + + + Toggle all + +
+ + + + + + + + + + + +
+
+ Create Gerty + + Update Gerty + + Cancel + +
+
+
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }} - + }) + {% endblock %} + +{% block styles %} + +{% endblock %} \ No newline at end of file diff --git a/lnbits/extensions/gerty/views_api.py b/lnbits/extensions/gerty/views_api.py index d175bb7ce..b5852ee6e 100644 --- a/lnbits/extensions/gerty/views_api.py +++ b/lnbits/extensions/gerty/views_api.py @@ -1,15 +1,17 @@ +import math from http import HTTPStatus import json import httpx import random import os +import time +from datetime import datetime from fastapi import Query from fastapi.params import Depends from lnurl import decode as decode_lnurl from loguru import logger from starlette.exceptions import HTTPException - from lnbits.core.crud import get_wallet_for_key from lnbits.core.crud import get_user from lnbits.core.services import create_invoice @@ -27,7 +29,7 @@ from ...settings import LNBITS_PATH @gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK) async def api_gertys( - all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) ): wallet_ids = [wallet.wallet.id] if all_wallets: @@ -39,9 +41,9 @@ async def api_gertys( @gerty_ext.post("/api/v1/gerty", status_code=HTTPStatus.CREATED) @gerty_ext.put("/api/v1/gerty/{gerty_id}", status_code=HTTPStatus.OK) async def api_link_create_or_update( - data: Gerty, - wallet: WalletTypeInfo = Depends(get_key_type), - gerty_id: str = Query(None), + data: Gerty, + wallet: WalletTypeInfo = Depends(get_key_type), + gerty_id: str = Query(None), ): if gerty_id: gerty = await get_gerty(gerty_id) @@ -63,9 +65,10 @@ async def api_link_create_or_update( return {**gerty.dict()} + @gerty_ext.delete("/api/v1/gerty/{gerty_id}") async def api_gerty_delete( - gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) + gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) ): gerty = await get_gerty(gerty_id) @@ -83,83 +86,271 @@ async def api_gerty_delete( ####################### -with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/satoshi.json')) as fd: - satoshiQuotes = json.load(fd) - @gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK) async def api_gerty_satoshi(): + with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/satoshi.json')) as fd: + satoshiQuotes = json.load(fd) return satoshiQuotes[random.randint(0, 100)] - -@gerty_ext.get("/api/v1/gerty/{gerty_id}") + + +@gerty_ext.get("/api/v1/gerty/pieterwielliequote", status_code=HTTPStatus.OK) +async def api_gerty_wuille(): + with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/pieter_wuille.json')) as fd: + data = json.load(fd) + return data['facts'][random.randint(0, (len(data['facts']) - 1))] + + +@gerty_ext.get("/api/v1/gerty/{gerty_id}/{p}") async def api_gerty_json( - gerty_id: str + gerty_id: str, + p: int = None # page number ): gerty = await get_gerty(gerty_id) - logger.debug(gerty.wallet) + if not gerty: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist." ) - gertyReturn = [] + display_preferences = json.loads(gerty.display_preferences) + + enabled_screen_count = 0 + + enabled_screens = [] + + for screen_slug in display_preferences: + is_screen_enabled = display_preferences[screen_slug] + if is_screen_enabled: + enabled_screen_count += 1 + enabled_screens.append(screen_slug) + + text = await get_screen_text(p, enabled_screens, gerty) + + next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1; + + # ln = [] + # if gerty.ln_stats and isinstance(gerty.mempool_endpoint, str): + # async with httpx.AsyncClient() as client: + # r = await client.get(gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest") + # if r: + # ln.append(r.json()) + + return { + "settings": { + "refreshTime": gerty.refresh_time, + "requestTimestamp": round(time.time()), + "nextScreenNumber": next_screen_number, + "showTextBoundRect": True, + "name": gerty.name + }, + "screen": { + "slug": get_screen_slug_by_index(p, enabled_screens), + "group": get_screen_slug_by_index(p, enabled_screens), + "text": text + } + } + + +# Get a screen slug by its position in the screens_list +def get_screen_slug_by_index(index: int, screens_list): + return list(screens_list)[index] + + +# Get a list of text items for the screen number +async def get_screen_text(screen_num: int, screens_list: dict, gerty): + screen_slug = get_screen_slug_by_index(screen_num, screens_list) + # first get the relevant slug from the display_preferences + logger.debug('screen_slug') + logger.debug(screen_slug) + # text = [] + if screen_slug == "lnbits_wallets_balance": + text = await get_lnbits_wallet_balances(gerty) + elif screen_slug == "fun_satoshi_quotes": + text = await get_satoshi_quotes() + elif screen_slug == "fun_pieter_wuille_facts": + text = await get_pieter_wuille_fact() + elif screen_slug == "fun_exchange_market_rate": + text = await get_exchange_rate(gerty) + elif screen_slug == "onchain_difficulty_epoch_progress": + text = await get_onchain_stat(screen_slug, gerty) + elif screen_slug == "onchain_difficulty_retarget_date": + text = await get_onchain_stat(screen_slug, gerty) + elif screen_slug == "onchain_difficulty_blocks_remaining": + text = await get_onchain_stat(screen_slug, gerty) + elif screen_slug == "onchain_difficulty_epoch_time_remaining": + text = await get_onchain_stat(screen_slug, gerty) + elif screen_slug == "mempool_recommended_fees": + text = await get_placeholder_text() + elif screen_slug == "mempool_tx_count": + text = await get_mempool_stat(screen_slug, gerty) + elif screen_slug == "mining_current_hash_rate": + text = await get_placeholder_text() + elif screen_slug == "mining_current_difficulty": + text = await get_placeholder_text() + elif screen_slug == "lightning_channel_count": + text = await get_placeholder_text() + elif screen_slug == "lightning_node_count": + text = await get_placeholder_text() + elif screen_slug == "lightning_tor_node_count": + text = await get_placeholder_text() + elif screen_slug == "lightning_clearnet_nodes": + text = await get_placeholder_text() + elif screen_slug == "lightning_unannounced_nodes": + text = await get_placeholder_text() + elif screen_slug == "lightning_average_channel_capacity": + text = await get_placeholder_text() + return text + + +async def get_lnbits_wallet_balances(gerty): # Get Wallet info wallets = [] + text = [] if gerty.lnbits_wallets != "": for lnbits_wallet in json.loads(gerty.lnbits_wallets): + wallet = await get_wallet_for_key(key=lnbits_wallet) + logger.debug(wallet) if wallet: wallets.append({ "name": wallet.name, "balance": wallet.balance_msat, "inkey": wallet.inkey, }) + text.append(get_text_item_dict(wallet.name, 20)) + text.append(get_text_item_dict(wallet.balance, 40)) + return text - #Get Satoshi quotes - satoshi = [] - if gerty.sats_quote: - quote = await api_gerty_satoshi() - if quote: - satoshi.append(await api_gerty_satoshi()) - #Get Exchange Value - exchange = [] +async def get_placeholder_text(): + return [ + get_text_item_dict("Some placeholder text", 15, 10, 50), + get_text_item_dict("Some placeholder text", 15, 10, 50) + ] + + +async def get_satoshi_quotes(): + # Get Satoshi quotes + text = [] + quote = await api_gerty_satoshi() + if quote: + if quote['text']: + text.append(get_text_item_dict(quote['text'], 15)) + if quote['date']: + text.append(get_text_item_dict(quote['date'], 15)) + return text + + +async def get_pieter_wuille_fact(): + text = [] + quote = await api_gerty_wuille() + if quote: + text.append(get_text_item_dict(quote, 15)) + text.append(get_text_item_dict("Pieter Wuille facts", 15)) + return text + + +# Get Exchange Value +async def get_exchange_rate(gerty): + text = [] if gerty.exchange != "": try: amount = await satoshis_amount_as_fiat(100000000, gerty.exchange) if amount: - exchange.append({ - "fiat": gerty.exchange, - "amount": amount, - }) + price = ('{0} {1}').format(format_number(amount), gerty.exchange) + text.append(get_text_item_dict("Current BTC price", 15)) + text.append(get_text_item_dict(price, 80)) except: pass + return text - onchain = [] - if gerty.onchain_stats and isinstance(gerty.mempool_endpoint, str): + +# A helper function get a nicely formated dict for the text +def get_text_item_dict(text: str, font_size: int, x_pos: int = None, y_pos: int = None): + text = { + "value": text, + "size": font_size + } + if x_pos is None and y_pos is None: + text['position'] = 'center' + else: + text['x'] = x_pos + text['y'] = y_pos + return text + + +async def get_onchain_stat(stat_slug: str, gerty): + text = [] + if isinstance(gerty.mempool_endpoint, str): async with httpx.AsyncClient() as client: - difficulty = [] - r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment") - if r: - difficulty.append(r.json()) - onchain.append({"difficulty":difficulty}) - mempool = [] - r = await client.get(gerty.mempool_endpoint + "/api/v1/fees/mempool-blocks") - if r: - mempool.append(r.json()) - onchain.append({"mempool":mempool}) - threed = [] - r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/3d") - if r: - threed.append(r.json()) - onchain.append({"threed":threed}) + if ( + stat_slug == "onchain_difficulty_epoch_progress" or + stat_slug == "onchain_difficulty_retarget_date" or + stat_slug == "onchain_difficulty_blocks_remaining" or + stat_slug == "onchain_difficulty_epoch_time_remaining" + ): + r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment") + if stat_slug == "onchain_difficulty_epoch_progress": + stat = round(r.json()['progressPercent']) + text.append(get_text_item_dict("Progress through current difficulty epoch", 15)) + text.append(get_text_item_dict("{0}%".format(stat), 80)) + elif stat_slug == "onchain_difficulty_retarget_date": + stat = r.json()['estimatedRetargetDate'] + dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M") + text.append(get_text_item_dict("Estimated date of next difficulty adjustment", 15)) + text.append(get_text_item_dict(dt, 40)) + elif stat_slug == "onchain_difficulty_blocks_remaining": + stat = r.json()['remainingBlocks'] + text.append(get_text_item_dict("Blocks remaining until next difficulty adjustment", 15)) + text.append(get_text_item_dict("{0}".format(format_number(stat)), 80)) + elif stat_slug == "onchain_difficulty_epoch_time_remaining": + stat = r.json()['remainingTime'] + text.append(get_text_item_dict("Blocks remaining until next difficulty adjustment", 15)) + text.append(get_text_item_dict(get_time_remaining(stat / 1000, 4), 20)) + return text - ln = [] - if gerty.ln_stats and isinstance(gerty.mempool_endpoint, str): +async def get_mempool_stat(stat_slug: str, gerty): + text = [] + if isinstance(gerty.mempool_endpoint, str): async with httpx.AsyncClient() as client: - r = await client.get(gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest") - if r: - ln.append(r.json()) + if ( + stat_slug == "mempool_tx_count" + ): + r = await client.get(gerty.mempool_endpoint + "/api/mempool") + if stat_slug == "mempool_tx_count": + stat = round(r.json()['count']) + text.append(get_text_item_dict("Transactions in the mempool", 15)) + text.append(get_text_item_dict("{0}".format(format_number(stat)), 80)) + return text - return {"name":gerty.name, "wallets":wallets, "sats_quote":satoshi, "exchange":exchange, "onchain":onchain, "ln":ln} +def get_date_suffix(dayNumber): + if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30: + return "th" + else: + return ["st", "nd", "rd"][dayNumber % 10 - 1] + +# format a number for nice display output +def format_number(number): + return ("{:,}".format(round(number))) +def get_time_remaining(seconds, granularity=2): + + intervals = ( + ('weeks', 604800), # 60 * 60 * 24 * 7 + ('days', 86400), # 60 * 60 * 24 + ('hours', 3600), # 60 * 60 + ('minutes', 60), + ('seconds', 1), + ) + + result = [] + + for name, count in intervals: + value = seconds // count + if value: + seconds -= value * count + if value == 1: + name = name.rstrip('s') + result.append("{} {}".format(round(value), name)) + return ', '.join(result[:granularity]) \ No newline at end of file