diff --git a/lnbits/extensions/copilot/README.md b/lnbits/extensions/copilot/README.md new file mode 100644 index 00000000..68a38809 --- /dev/null +++ b/lnbits/extensions/copilot/README.md @@ -0,0 +1,3 @@ +# Stream Copilot + +Tool to help streamers accept sats for tips diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py new file mode 100644 index 00000000..3b41c4c9 --- /dev/null +++ b/lnbits/extensions/copilot/__init__.py @@ -0,0 +1,13 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_copilot") + + +copilot_ext: Blueprint = Blueprint( + "copilot", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/copilot/config.json b/lnbits/extensions/copilot/config.json new file mode 100644 index 00000000..02275230 --- /dev/null +++ b/lnbits/extensions/copilot/config.json @@ -0,0 +1,8 @@ +{ + "name": "StreamCopilot", + "short_description": "Tipping and animations for streamers", + "icon": "face", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/copilot/crud.py b/lnbits/extensions/copilot/crud.py new file mode 100644 index 00000000..67f9b92e --- /dev/null +++ b/lnbits/extensions/copilot/crud.py @@ -0,0 +1,71 @@ +from typing import List, Optional, Union + +# from lnbits.db import open_ext_db +from . import db +from .models import Copilots + +from lnbits.helpers import urlsafe_short_hash + +from quart import jsonify + + +###############COPILOTS########################## + + +async def create_copilot( + title: str, + user: str, + animation: str = None, + show_message: Optional[str] = None, + amount: Optional[str] = None, + lnurl_title: Optional[str] = None, +) -> Copilots: + copilot_id = urlsafe_short_hash() + + await db.execute( + """ + INSERT INTO copilots ( + id, + user, + title, + animation, + show_message, + amount, + lnurl_title + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + copilot_id, + user, + title, + animation, + show_message, + amount, + lnurl_title + ), + ) + return await get_copilot(copilot_id) + + +async def update_copilot(copilot_id: str, **kwargs) -> Optional[Copilots]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE copilots SET {q} WHERE id = ?", (*kwargs.values(), copilot_id) + ) + row = await db.fetchone("SELECT * FROM copilots WHERE id = ?", (copilot_id,)) + return Copilots.from_row(row) if row else None + + +async def get_copilot(copilot_id: str) -> Copilots: + row = await db.fetchone("SELECT * FROM copilots WHERE id = ?", (copilot_id,)) + return Copilots.from_row(row) if row else None + + +async def get_copilots(user: str) -> List[Copilots]: + rows = await db.fetchall("SELECT * FROM copilots WHERE user = ?", (user,)) + return [Copilots.from_row(row) for row in rows] + + +async def delete_copilot(copilot_id: str) -> None: + await db.execute("DELETE FROM copilots WHERE id = ?", (copilot_id,)) diff --git a/lnbits/extensions/copilot/migrations.py b/lnbits/extensions/copilot/migrations.py new file mode 100644 index 00000000..6f46bf21 --- /dev/null +++ b/lnbits/extensions/copilot/migrations.py @@ -0,0 +1,19 @@ +async def m001_initial(db): + """ + Initial copilot table. + """ + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS copilots ( + id TEXT NOT NULL PRIMARY KEY, + user TEXT, + title TEXT, + animation INTEGER, + show_message TEXT, + amount INTEGER, + lnurl_title TEXT, + timestamp TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py new file mode 100644 index 00000000..b9d3325f --- /dev/null +++ b/lnbits/extensions/copilot/models.py @@ -0,0 +1,24 @@ +from sqlite3 import Row +from typing import NamedTuple +import time + + +class Copilots(NamedTuple): + id: str + user: str + title: str + animation: str + show_message: bool + amount: int + lnurl_title: str + + @classmethod + def from_row(cls, row: Row) -> "Charges": + return cls(**dict(row)) + + @property + def paid(self): + if self.balance >= self.amount: + return True + else: + return False diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html new file mode 100644 index 00000000..859eefe4 --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/_api_docs.html @@ -0,0 +1,169 @@ + + +

+ StreamCopilot: get tips and show an animation
+ + Created by, Ben Arc +

+
+ + + + + POST /satspay/api/v1/charge +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge -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: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}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: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET /satspay/api/v1/charges +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satspay/api/v1/charges/balance/<charge_id> +
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/copilot/templates/copilot/display.html b/lnbits/extensions/copilot/templates/copilot/display.html new file mode 100644 index 00000000..acee075f --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/display.html @@ -0,0 +1,318 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
{{ charge.description }}
+
+
+
+
Time elapsed
+
+
+
Charge paid
+
+
+ + + + Awaiting payment... + + {% raw %} {{ newTimeLeft }} {% endraw %} + + + +
+
+
+
+ Charge ID: {{ charge.id }} +
+ {% raw %} Total to pay: {{ charge_amount }}sats
+ Amount paid: {{ charge_balance }}

+ Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} +
+
+ +
+
+
+ + + bitcoin onchain payment method not available + + + + pay with lightning + +
+
+ + + bitcoin lightning payment method not available + + + + pay onchain + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ Pay this
+ lightning-network invoice
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ Send {{ charge.amount }}sats
+ to this onchain address
+
+ + + + + +
+ Copy address +
+
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/copilot/templates/copilot/index.html b/lnbits/extensions/copilot/templates/copilot/index.html new file mode 100644 index 00000000..2dbeb9bd --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/index.html @@ -0,0 +1,521 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New copilot instance + + + + + + +
+
+
Copilots
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits StreamCopilot Extension
+
+ + + {% include "copilot/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ Create Copilot + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/copilot/views.py b/lnbits/extensions/copilot/views.py new file mode 100644 index 00000000..a63782f7 --- /dev/null +++ b/lnbits/extensions/copilot/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template, jsonify +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import copilot_ext +from .crud import get_copilot + + +@copilot_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("copilot/index.html", user=g.user) + + +@copilot_ext.route("/") +async def display(copilot_id): + copilot = await get_copilot(copilot_id) or abort( + HTTPStatus.NOT_FOUND, "Charge link does not exist." + ) + return await render_template("copilot/display.html", copilot=copilot) diff --git a/lnbits/extensions/copilot/views_api.py b/lnbits/extensions/copilot/views_api.py new file mode 100644 index 00000000..18eb4152 --- /dev/null +++ b/lnbits/extensions/copilot/views_api.py @@ -0,0 +1,86 @@ +import hashlib +from quart import g, jsonify, url_for +from http import HTTPStatus +import httpx + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from lnbits.extensions.copilot import copilot_ext +from .crud import ( + create_copilot, + update_copilot, + get_copilot, + get_copilots, + delete_copilot, +) + +#############################COPILOT########################## + + +@copilot_ext.route("/api/v1/copilot", methods=["POST"]) +@copilot_ext.route("/api/v1/copilot/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "title": {"type": "string", "empty": False, "required": True}, + "animation": {"type": "string", "empty": False, "required": True}, + "show_message": {"type": "integer", "empty": False, "required": True}, + "amount": {"type": "integer", "empty": False, "required": True}, + } +) +async def api_copilot_create_or_update(copilot_id=None): + if not copilot_id: + copilot = await create_copilot(user=g.wallet.user, **g.data) + return jsonify(copilot._asdict()), HTTPStatus.CREATED + else: + copilot = await update_copilot(copilot_id=copilot_id, **g.data) + return jsonify(copilot._asdict()), HTTPStatus.OK + + +@copilot_ext.route("/api/v1/copilot", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_copilot_retrieve(copilot_id): + copilots = await get_copilots(user=g.wallet.user) + + if not copilots: + return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND + + return ( + jsonify( + { + copilots._asdict() + } + ), + HTTPStatus.OK, + ) + +@copilot_ext.route("/api/v1/copilot/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_copilot_retrieve(copilot_id): + copilot = await get_copilot(copilot_id) + + if not copilot: + return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND + + return ( + jsonify( + { + copilot._asdict() + } + ), + HTTPStatus.OK, + ) + + +@copilot_ext.route("/api/v1/copilot/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_copilot_delete(copilot_id): + copilot = await get_copilot(copilot_id) + + if not copilot: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await delete_copilot(copilot_id) + + return "", HTTPStatus.NO_CONTENT \ No newline at end of file