Basics working

This commit is contained in:
ben 2022-09-23 14:38:55 +01:00
parent b0bedd53df
commit 8d1f59cf46
11 changed files with 1938 additions and 0 deletions

View file

@ -0,0 +1,13 @@
# Gerty
## Your desktop bitcoin assistant
Buy here `<link>`
blah blah blah
### Usage
1. Enable extension
2. Fill out form
3. point gerty at the server and give it the Gerty ID

View file

@ -0,0 +1,20 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_gerty")
gerty_ext: APIRouter = APIRouter(prefix="/gerty", tags=["Gerty"])
def gerty_renderer():
return template_renderer(["lnbits/extensions/gerty/templates"])
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Gerty",
"short_description": "Desktop bitcoin Assistant",
"icon": "sentiment_satisfied",
"contributors": ["arcbtc"]
}

View file

@ -0,0 +1,51 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Gerty
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, sats_quote, exchange, onchain_sats, ln_stats)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
gerty_id,
data.name,
data.wallet,
data.lnbits_wallets,
data.sats_quote,
data.exchange,
data.onchain_sats,
data.ln_stats,
),
)
gerty = await get_gerty(gerty_id)
assert gerty, "Newly created gerty couldn't be retrieved"
return gerty
async def get_gerty(gerty_id: str) -> Optional[Gerty]:
row = await db.fetchone("SELECT * FROM gerty.gertys WHERE id = ?", (gerty_id,))
return Gerty(**row) if row else None
async def get_gertys(wallet_ids: Union[str, List[str]]) -> List[Gerty]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM gerty.gertys WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Gerty(**row) for row in rows]
async def delete_gerty(gerty_id: str) -> None:
await db.execute("DELETE FROM gerty.gertys WHERE id = ?", (gerty_id,))

View file

@ -0,0 +1,17 @@
async def m001_initial(db):
"""
Initial gertys table.
"""
await db.execute(
"""
CREATE TABLE gerty.gertys (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
lnbits_wallets TEXT NOT NULL,
sats_quote BOOL NOT NULL,
exchange TEXT NOT NULL,
onchain_sats BOOL NOT NULL,
ln_stats BOOL NOT NULL
);
"""
)

View file

@ -0,0 +1,21 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel
class Gerty(BaseModel):
id: str
name: str
wallet: str
lnbits_wallets: str # Wallets to keep an eye on, {"wallet-id": "wallet-read-key, etc"}
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_sats: bool = Query(False) # Onchain stats
ln_stats: bool = Query(False) # ln Sats
@classmethod
def from_row(cls, row: Row) -> "Gerty":
return cls(**dict(row))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/gerty"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List Gerty">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /gerty/api/v1/gertys</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;gerty_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}gerty/api/v1/gertys -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a Gerty">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /gerty/api/v1/gertys</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}gerty/api/v1/gertys -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a Gerty"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/gerty/api/v1/gertys/&lt;gerty_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}gerty/api/v1/gertys/&lt;gerty_id&gt; -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,475 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Gerty</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Gerty</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="gertys"
row-key="id"
:columns="gertysTable.columns"
:pagination.sync="gertysTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.gerty"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteGerty(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Gerty extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "gerty/_api_docs.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createGerty" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Name"
placeholder="Son of Gerty"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.lnbits_wallets"
label="Wallets to watch (invoice keys, seperated by comma)"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.exchange"
:options="currencyOptions"
label="Exchange rate"
></q-select>
<q-toggle
v-model="formDialog.data.sats_quote"
label="Satoshi Quotes"
><q-tooltip>Gets random quotes from satoshi</q-tooltip></q-toggle>
<q-toggle
v-model="formDialog.data.onchain_stats"
label="Onchain Statistics"
><q-tooltip>Gets Onchain Statistics</q-tooltip></q-toggle>
<q-toggle
v-model="formDialog.data.ln_stats"
label="LN Statistics"
><q-tooltip>Gets Lightning-Network Statistics</q-tooltip></q-toggle>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.walletOptions == null || formDialog.data.name == null"
type="submit"
>Create Gerty</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapGerty = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.gerty = ['/gerty/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
gertys: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
gertysTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'lnbits_wallets',
align: 'left',
label: 'Wallets',
field: 'lnbits_wallets'
},
{
name: 'exchange',
align: 'left',
label: 'Exchange',
field: 'exchange'
},
{
name: 'sats_quote',
align: 'left',
label: 'Sats Quote',
field: 'sats_quote'
},
{
name: 'onchain_stats',
align: 'left',
label: 'Onchain Stats',
field: 'onchain_stats'
},
{
name: 'ln_stats',
align: 'left',
label: 'LN Stats',
field: 'ln_stats'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {sats_quote: false,
onchain_sats: false,
ln_stats: false}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {sats_quote: false,
onchain_sats: false,
ln_stats: false}
},
getGertys: function () {
var self = this
LNbits.api
.request(
'GET',
'/gerty/api/v1/gertys?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.gertys = response.data.map(function (obj) {
return mapGerty(obj)
})
})
},
createGerty: function () {
var data = {
name: this.formDialog.data.name,
currency: this.formDialog.data.currency,
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
LNbits.api
.request(
'POST',
'/gerty/api/v1/gertys',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.gertys.push(mapGerty(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteGerty: function (gertyId) {
var self = this
var gerty = _.findWhere(this.gertys, {id: gertyId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this Gerty?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/gerty/api/v1/gertys/' + gertyId,
_.findWhere(self.g.user.wallets, {id: gerty.wallet}).adminkey
)
.then(function (response) {
self.gertys = _.reject(self.gertys, function (obj) {
return obj.id == gertyId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.gertysTable.columns, this.gertys)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getGertys()
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,23 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE
from . import gerty_ext, gerty_renderer
from .crud import get_gerty
templates = Jinja2Templates(directory="templates")
@gerty_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return gerty_renderer().TemplateResponse(
"gerty/index.html", {"request": request, "user": user.dict()}
)

View file

@ -0,0 +1,134 @@
from http import HTTPStatus
import json
import httpx
import random
import os
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_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from fastapi.templating import Jinja2Templates
from . import gerty_ext
from .crud import create_gerty, delete_gerty, get_gerty, get_gertys
from .models import Gerty
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
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)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [gerty.dict() for gerty in await get_gertys(wallet_ids)]
@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),
):
if gerty_id:
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist"
)
if gerty.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Come on, seriously, this isn't your Gerty!",
)
data.wallet = wallet.wallet.id
gerty = await update_gerty(gerty_id, **data.dict())
else:
gerty = await create_gerty(wallet_id=wallet.wallet.id, data=data)
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 = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
)
if gerty.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Gerty.")
await delete_gerty(gerty_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#######################
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():
return satoshiQuotes[random.randint(0, 100)]
@gerty_ext.get("/api/v1/gerty/{gerty_id}")
async def api_gerty_json(
gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
)
if gerty.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Come on, seriously, this isn't your Gerty!",
)
gertyReturn = []
if gerty.lnbits_wallets != "":
gertyReturn.append(gerty.lnbitsWallets)
if gerty.sats_quote:
gertyReturn.append(await api_gerty_satoshi())
if gerty.exchange != "":
try:
gertyReturn.append(await fiat_amount_as_satoshis(1, gerty.exchange))
except:
pass
if gerty.onchain_sats:
async with httpx.AsyncClient() as client:
r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment")
gertyReturn.append({"difficulty-adjustment": json.dumps(r)})
r = await client.get(gerty.mempool_endpoint + "/api/v1/fees/mempool-blocks")
gertyReturn.append({"mempool-blocks": json.dumps(r)})
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/3d")
gertyReturn.append({"3d": json.dumps(r)})
if gerty.ln_sats:
async with httpx.AsyncClient() as client:
r = await client.get(gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest")
gertyReturn.append({"latest": json.dumps(r)})
return gertyReturn