From cfe7fc5e5850cf679f3b0c0822d5ae64a7b3badd Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Oct 2021 12:44:45 +0100 Subject: [PATCH] streamalerts converted untested --- lnbits/extensions/streamalerts/README.md | 39 ++ lnbits/extensions/streamalerts/__init__.py | 17 + lnbits/extensions/streamalerts/config.json | 6 + lnbits/extensions/streamalerts/crud.py | 283 ++++++++++ lnbits/extensions/streamalerts/migrations.py | 35 ++ lnbits/extensions/streamalerts/models.py | 65 +++ .../templates/streamalerts/_api_docs.html | 18 + .../templates/streamalerts/display.html | 97 ++++ .../templates/streamalerts/index.html | 502 ++++++++++++++++++ lnbits/extensions/streamalerts/views.py | 39 ++ lnbits/extensions/streamalerts/views_api.py | 269 ++++++++++ 11 files changed, 1370 insertions(+) create mode 100644 lnbits/extensions/streamalerts/README.md create mode 100644 lnbits/extensions/streamalerts/__init__.py create mode 100644 lnbits/extensions/streamalerts/config.json create mode 100644 lnbits/extensions/streamalerts/crud.py create mode 100644 lnbits/extensions/streamalerts/migrations.py create mode 100644 lnbits/extensions/streamalerts/models.py create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/display.html create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/index.html create mode 100644 lnbits/extensions/streamalerts/views.py create mode 100644 lnbits/extensions/streamalerts/views_api.py diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md new file mode 100644 index 00000000..726ffe76 --- /dev/null +++ b/lnbits/extensions/streamalerts/README.md @@ -0,0 +1,39 @@ +

Stream Alerts

+

Integrate Bitcoin Donations into your livestream alerts

+The StreamAlerts extension allows you to integrate Bitcoin Lightning (and on-chain) paymnents in to your existing Streamlabs alerts! + +![image](https://user-images.githubusercontent.com/28876473/127759038-aceb2503-6cff-4061-8b81-c769438ebcaa.png) + +

How to set it up

+ +At the moment, the only service that has an open API to work with is Streamlabs, so this setup requires linking your Twitch/YouTube/Facebook account to Streamlabs. + +1. Log into [Streamlabs](https://streamlabs.com/login?r=https://streamlabs.com/dashboard). +1. Navigate to the API settings page to register an App: +![image](https://user-images.githubusercontent.com/28876473/127759145-710d53b6-3c19-4815-812a-9a6279d1b8bb.png) +![image](https://user-images.githubusercontent.com/28876473/127759182-da8a27cb-bb59-48fa-868e-c8892080ae98.png) +![image](https://user-images.githubusercontent.com/28876473/127759201-7c28e9f1-6286-42be-a38e-1c377a86976b.png) +1. Fill out the form with anything it will accept as valid. Most fields can be gibberish, as the application is not supposed to ever move past the "testing" stage and is for your personal use only. +In the "Whitelist Users" field, input the username of a Twitch account you control. While this feature is *technically* limited to Twitch, you can use the alerts overlay for donations on YouTube and Facebook as well. +For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. +Then, hit create: +![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) +1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions: +![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) +1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): +![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) +![image](https://user-images.githubusercontent.com/28876473/127759526-7f2a4980-39ea-4e58-8af0-c9fb381e5524.png) +1. Right-click and copy the "Redirect URI for Streamlabs" link (you might have to refresh the page for the text to turn into a link) and input it into the "Redirect URI" field for your Streamelements App, and hit "Save Settings": +![image](https://user-images.githubusercontent.com/28876473/127759570-52d34c07-6857-467b-bcb3-54e10679aedb.png) +![image](https://user-images.githubusercontent.com/28876473/127759604-b3c8270b-bd02-44df-a525-9d85af337d14.png) +1. You can now authenticate your app on LNbits by clicking on this button and following the instructions. Make sure to log in with the Twitch account you entered in the "Whitelist Users" field: +![image](https://user-images.githubusercontent.com/28876473/127759642-a3787a6a-3cab-4c44-a2d4-ab45fbbe3fab.png) +![image](https://user-images.githubusercontent.com/28876473/127759681-7289e7f6-0ff1-4988-944f-484040f6b9c7.png) +If everything worked correctly, you should now be redirected back to LNbits. When scrolling all the way right, you should now see that the service has been authenticated: +![image](https://user-images.githubusercontent.com/28876473/127759715-7e839261-d505-4e07-a0e4-f347f114149f.png) +You can now share the link to your donations page, which you can get here: +![image](https://user-images.githubusercontent.com/28876473/127759730-8dd11e61-0186-4935-b1ed-b66d35b05043.png) +![image](https://user-images.githubusercontent.com/28876473/127759747-67d3033f-6ef1-4033-b9b1-51b87189ff8b.png) +Of course, this has to be available publicly on the internet (or, depending on your viewers' technical ability, over Tor). +When your viewers donate to you, these donations will now show up in your Streamlabs donation feed, as well as your alerts overlay (if it is configured to include donations). +

CONGRATS! Let the sats flow!

diff --git a/lnbits/extensions/streamalerts/__init__.py b/lnbits/extensions/streamalerts/__init__.py new file mode 100644 index 00000000..00301f6d --- /dev/null +++ b/lnbits/extensions/streamalerts/__init__.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_streamalerts") + +streamalerts_ext: APIRouter = APIRouter( + prefix="/streamalerts", + tags=["streamalerts"] +) + +def streamalerts_renderer(): + return template_renderer(["lnbits/extensions/streamalerts/templates"]) + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/streamalerts/config.json b/lnbits/extensions/streamalerts/config.json new file mode 100644 index 00000000..f94886c9 --- /dev/null +++ b/lnbits/extensions/streamalerts/config.json @@ -0,0 +1,6 @@ +{ + "name": "Stream Alerts", + "short_description": "Bitcoin donations in stream alerts", + "icon": "notifications_active", + "contributors": ["Fittiboy"] +} diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py new file mode 100644 index 00000000..5992cb77 --- /dev/null +++ b/lnbits/extensions/streamalerts/crud.py @@ -0,0 +1,283 @@ +from http import HTTPStatus +from typing import Optional + +import httpx + +from lnbits.core.crud import get_wallet +from lnbits.db import SQLITE +from lnbits.helpers import urlsafe_short_hash + +from ..satspay.crud import delete_charge # type: ignore +from . import db +from .models import CreateService, Donation, Service + + +async def get_service_redirect_uri(request, service_id): + """Return the service's redirect URI, to be given to the third party API""" + uri_base = request.scheme + "://" + uri_base += request.headers["Host"] + "/streamalerts/api/v1" + redirect_uri = uri_base + f"/authenticate/{service_id}" + return redirect_uri + + +async def get_charge_details(service_id): + """Return the default details for a satspay charge + + These might be different depending for services implemented in the future. + """ + details = { + "time": 1440, + } + service = await get_service(service_id) + wallet_id = service.wallet + wallet = await get_wallet(wallet_id) + user = wallet.user + details["user"] = user + details["lnbitswallet"] = wallet_id + details["onchainwallet"] = service.onchain + return details + + +async def create_donation( + id: str, + wallet: str, + cur_code: str, + sats: int, + amount: float, + service: int, + name: str = "Anonymous", + message: str = "", + posted: bool = False, +) -> Donation: + """Create a new Donation""" + await db.execute( + """ + INSERT INTO streamalerts.Donations ( + id, + wallet, + name, + message, + cur_code, + sats, + amount, + service, + posted + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (id, wallet, name, message, cur_code, sats, amount, service, posted), + ) + + donation = await get_donation(id) + assert donation, "Newly created donation couldn't be retrieved" + return donation + + +async def post_donation(donation_id: str) -> tuple: + """Post donations to their respective third party APIs + + If the donation has already been posted, it will not be posted again. + """ + donation = await get_donation(donation_id) + if not donation: + return {"message": "Donation not found!"} + if donation.posted: + return {"message": "Donation has already been posted!"} + + service = await get_service(donation.service) + assert service, "Couldn't fetch service to donate to" + + if service.servicename == "Streamlabs": + url = "https://streamlabs.com/api/v1.0/donations" + data = { + "name": donation.name[:25], + "message": donation.message[:255], + "identifier": "LNbits", + "amount": donation.amount, + "currency": donation.cur_code.upper(), + "access_token": service.token, + } + async with httpx.AsyncClient() as client: + response = await client.post(url, data=data) + print(response.json()) + status = [s for s in list(HTTPStatus) if s == response.status_code][0] + elif service.servicename == "StreamElements": + return {"message": "StreamElements not yet supported!"} + else: + return {"message": "Unsopported servicename"} + await db.execute( + "UPDATE streamalerts.Donations SET posted = 1 WHERE id = ?", (donation_id,) + ) + return response.json() + + +async def create_service( + data: CreateService +) -> Service: + """Create a new Service""" + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO streamalerts.Services ( + twitchuser, + client_id, + client_secret, + wallet, + servicename, + authenticated, + state, + onchain + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + {returning} + """, + ( + data.twitchuser, + data.client_id, + data.client_secret, + data.wallet, + data.servicename, + False, + urlsafe_short_hash(), + data.onchain, + ), + ) + if db.type == SQLITE: + service_id = result._result_proxy.lastrowid + else: + service_id = result[0] + + service = await get_service(service_id) + assert service + return service + + +async def get_service(service_id: int, by_state: str = None) -> Optional[Service]: + """Return a service either by ID or, available, by state + + Each Service's donation page is reached through its "state" hash + instead of the ID, preventing accidental payments to the wrong + streamer via typos like 2 -> 3. + """ + if by_state: + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE state = ?", (by_state,) + ) + else: + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) + ) + return Service.from_row(row) if row else None + + +async def get_services(wallet_id: str) -> Optional[list]: + """Return all services belonging assigned to the wallet_id""" + rows = await db.fetchall( + "SELECT * FROM streamalerts.Services WHERE wallet = ?", (wallet_id,) + ) + return [Service.from_row(row) for row in rows] if rows else None + + +async def authenticate_service(service_id, code, redirect_uri): + """Use authentication code from third party API to retreive access token""" + # The API token is passed in the querystring as 'code' + service = await get_service(service_id) + wallet = await get_wallet(service.wallet) + user = wallet.user + url = "https://streamlabs.com/api/v1.0/token" + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": service.client_id, + "client_secret": service.client_secret, + "redirect_uri": redirect_uri, + } + print(data) + async with httpx.AsyncClient() as client: + response = (await client.post(url, data=data)).json() + print(response) + token = response["access_token"] + success = await service_add_token(service_id, token) + return f"/streamalerts/?usr={user}", success + + +async def service_add_token(service_id, token): + """Add access token to its corresponding Service + + This also sets authenticated = 1 to make sure the token + is not overwritten. + Tokens for Streamlabs never need to be refreshed. + """ + if (await get_service(service_id)).authenticated: + return False + await db.execute( + "UPDATE streamalerts.Services SET authenticated = 1, token = ? where id = ?", + ( + token, + service_id, + ), + ) + return True + + +async def delete_service(service_id: int) -> None: + """Delete a Service and all corresponding Donations""" + await db.execute("DELETE FROM streamalerts.Services WHERE id = ?", (service_id,)) + rows = await db.fetchall( + "SELECT * FROM streamalerts.Donations WHERE service = ?", (service_id,) + ) + for row in rows: + await delete_donation(row["id"]) + + +async def get_donation(donation_id: str) -> Optional[Donation]: + """Return a Donation""" + row = await db.fetchone( + "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) + ) + return Donation.from_row(row) if row else None + + +async def get_donations(wallet_id: str) -> Optional[list]: + """Return all streamalerts.Donations assigned to wallet_id""" + rows = await db.fetchall( + "SELECT * FROM streamalerts.Donations WHERE wallet = ?", (wallet_id,) + ) + return [Donation.from_row(row) for row in rows] if rows else None + + +async def delete_donation(donation_id: str) -> None: + """Delete a Donation and its corresponding statspay charge""" + await db.execute("DELETE FROM streamalerts.Donations WHERE id = ?", (donation_id,)) + await delete_charge(donation_id) + + +async def update_donation(donation_id: str, **kwargs) -> Donation: + """Update a Donation""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE streamalerts.Donations SET {q} WHERE id = ?", + (*kwargs.values(), donation_id), + ) + row = await db.fetchone( + "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) + ) + assert row, "Newly updated donation couldn't be retrieved" + return Donation(**row) + + +async def update_service(service_id: str, **kwargs) -> Service: + """Update a service""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE streamalerts.Services SET {q} WHERE id = ?", + (*kwargs.values(), service_id), + ) + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) + ) + assert row, "Newly updated service couldn't be retrieved" + return Service(**row) diff --git a/lnbits/extensions/streamalerts/migrations.py b/lnbits/extensions/streamalerts/migrations.py new file mode 100644 index 00000000..1b0cea37 --- /dev/null +++ b/lnbits/extensions/streamalerts/migrations.py @@ -0,0 +1,35 @@ +async def m001_initial(db): + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS streamalerts.Services ( + id {db.serial_primary_key}, + state TEXT NOT NULL, + twitchuser TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + wallet TEXT NOT NULL, + onchain TEXT, + servicename TEXT NOT NULL, + authenticated BOOLEAN NOT NULL, + token TEXT + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS streamalerts.Donations ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + message TEXT NOT NULL, + cur_code TEXT NOT NULL, + sats INT NOT NULL, + amount FLOAT NOT NULL, + service INTEGER NOT NULL, + posted BOOLEAN NOT NULL, + FOREIGN KEY(service) REFERENCES {db.references_schema}Services(id) + ); + """ + ) diff --git a/lnbits/extensions/streamalerts/models.py b/lnbits/extensions/streamalerts/models.py new file mode 100644 index 00000000..f8ec7408 --- /dev/null +++ b/lnbits/extensions/streamalerts/models.py @@ -0,0 +1,65 @@ +from sqlite3 import Row +from typing import Optional + +from fastapi.params import Query +from pydantic.main import BaseModel + + +class CreateService(BaseModel): + twitchuser: str = Query(...) + client_id: str = Query(...) + client_secret: str = Query(...) + wallet: str = Query(...) + servicename: str = Query(...) + onchain: str = Query(None) + +class CreateDonation(BaseModel): + name: str = Query("Anonymous") + sats: int = Query(..., ge=1) + service: int = Query(...) + message: str = Query("") + +class ValidateDonation(BaseModel): + id: str = Query(...) + + +class Donation(BaseModel): + """A Donation simply contains all the necessary information about a + user's donation to a streamer + """ + + id: str # This ID always corresponds to a satspay charge ID + wallet: str + name: str # Name of the donor + message: str # Donation message + cur_code: str # Three letter currency code accepted by Streamlabs + sats: int + amount: float # The donation amount after fiat conversion + service: int # The ID of the corresponding Service + posted: bool # Whether the donation has already been posted to a Service + + @classmethod + def from_row(cls, row: Row) -> "Donation": + return cls(**dict(row)) + + +class Service(BaseModel): + """A Service represents an integration with a third-party API + + Currently, Streamlabs is the only supported Service. + """ + + id: int + state: str # A random hash used during authentication + twitchuser: str # The Twitch streamer's username + client_id: str # Third party service Client ID + client_secret: str # Secret corresponding to the Client ID + wallet: str + onchain: Optional[str] + servicename: str # Currently, this will just always be "Streamlabs" + authenticated: bool # Whether a token (see below) has been acquired yet + token: Optional[int] # The token with which to authenticate requests + + @classmethod + def from_row(cls, row: Row) -> "Service": + return cls(**dict(row)) diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html new file mode 100644 index 00000000..33b52f15 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html @@ -0,0 +1,18 @@ + + +

+ Stream Alerts: Integrate Bitcoin into your stream alerts! +

+

+ Accept Bitcoin donations on Twitch, and integrate them into your alerts. + Present your viewers with a simple donation page, and add those donations + to Streamlabs to play alerts on your stream!
+ For detailed setup instructions, check out + this guide!
+ + Created by, Fitti +

+
+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/display.html b/lnbits/extensions/streamalerts/templates/streamalerts/display.html new file mode 100644 index 00000000..a10e64d8 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/display.html @@ -0,0 +1,97 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
Donate Bitcoin to {{ twitchuser }}
+
+ + + + +
+ Submit +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html new file mode 100644 index 00000000..46d1bb31 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -0,0 +1,502 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Service + + + + + +
+
+
Services
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Donations
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Stream Alerts extension +
+
+ + + {% include "streamalerts/_api_docs.html" %} + +
+
+ + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+ + + + +
+ Update Service + + Create Service + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py new file mode 100644 index 00000000..bd6c246d --- /dev/null +++ b/lnbits/extensions/streamalerts/views.py @@ -0,0 +1,39 @@ +from http import HTTPStatus + +from fastapi.param_functions import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import streamalerts_ext, streamalerts_renderer +from .crud import get_service + +templates = Jinja2Templates(directory="templates") + + +@streamalerts_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + """Return the extension's settings page""" + return streamalerts_renderer().TemplateResponse("streamalerts/index.html", {"request": request, "user": user.dict()}) + + +@streamalerts_ext.get("/{state}") +async def donation(state, request: Request): + """Return the donation form for the Service corresponding to state""" + service = await get_service(0, by_state=state) + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist." + ) + return streamalerts_renderer().TemplateResponse( + "streamalerts/display.html", + { + "request": request, + "twitchuser": service.twitchuser, + "service":service.id + } + ) diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py new file mode 100644 index 00000000..a90b4ae4 --- /dev/null +++ b/lnbits/extensions/streamalerts/views_api.py @@ -0,0 +1,269 @@ +from http import HTTPStatus + +from fastapi.params import Depends, Query +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import RedirectResponse + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.streamalerts.models import ( + CreateDonation, + CreateService, + ValidateDonation, +) +from lnbits.utils.exchange_rates import btc_price + +from ..satspay.crud import create_charge, get_charge +from . import streamalerts_ext +from .crud import ( + authenticate_service, + create_donation, + create_service, + delete_donation, + delete_service, + get_charge_details, + get_donation, + get_donations, + get_service, + get_service_redirect_uri, + get_services, + post_donation, + update_donation, + update_service, +) + + +@streamalerts_ext.post("/api/v1/services") +async def api_create_service(data : CreateService, wallet: WalletTypeInfo = Depends(get_key_type)): + """Create a service, which holds data about how/where to post donations""" + try: + service = await create_service(data=data) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) + ) + + return service.dict() + + +@streamalerts_ext.get("/api/v1/getaccess/{service_id}") +async def api_get_access(service_id, request: Request): + """Redirect to Streamlabs' Approve/Decline page for API access for Service + with service_id + """ + service = await get_service(service_id) + if service: + redirect_uri = await get_service_redirect_uri(request, service_id) + params = { + "response_type": "code", + "client_id": service.client_id, + "redirect_uri": redirect_uri, + "scope": "donations.create", + "state": service.state, + } + endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?" + querystring = "&".join([f"{key}={value}" for key, value in params.items()]) + redirect_url = endpoint_url + querystring + return RedirectResponse(redirect_url) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Service does not exist!" + ) + +@streamalerts_ext.get("/api/v1/authenticate/{service_id}") +async def api_authenticate_service(service_id, request: Request, code: str = Query(...), state: str = Query(...)): + """Endpoint visited via redirect during third party API authentication + + If successful, an API access token will be added to the service, and + the user will be redirected to index.html. + """ + + service = await get_service(service_id) + if service.state != state: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="State doesn't match!" + ) + + redirect_uri = request.scheme + "://" + request.headers["Host"] + redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" + url, success = await authenticate_service(service_id, code, redirect_uri) + if success: + return RedirectResponse(url) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Service already authenticated!" + ) + + +@streamalerts_ext.post("/api/v1/donations") +async def api_create_donation(data: CreateDonation, request: Request): + """Take data from donation form and return satspay charge""" + # Currency is hardcoded while frotnend is limited + cur_code = "USD" + sats = data.sats + message = data.message + # Fiat amount is calculated here while frontend is limited + price = await btc_price(cur_code) + amount = sats * (10 ** (-8)) * price + webhook_base = request.scheme + "://" + request.headers["Host"] + service_id = data.service + service = await get_service(service_id) + charge_details = await get_charge_details(service.id) + name = data.name + + description = f"{sats} sats donation from {name} to {service.twitchuser}" + charge = await create_charge( + amount=sats, + completelink=f"https://twitch.tv/{service.twitchuser}", + completelinktext="Back to Stream!", + webhook=webhook_base + "/streamalerts/api/v1/postdonation", + description=description, + **charge_details, + ) + await create_donation( + id=charge.id, + wallet=service.wallet, + message=message, + name=name, + cur_code=cur_code, + sats=data.sats, + amount=amount, + service=data.service + ) + return {"redirect_url": f"/satspay/{charge.id}"} + + +@streamalerts_ext.post("/api/v1/postdonation") +async def api_post_donation(request: Request, data: ValidateDonation): + """Post a paid donation to Stremalabs/StreamElements. + This endpoint acts as a webhook for the SatsPayServer extension.""" + + donation_id = data.id + charge = await get_charge(donation_id) + if charge and charge.paid: + return await post_donation(donation_id) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Not a paid charge!" + ) + + +@streamalerts_ext.get("/api/v1/services") +async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)): + """Return list of all services assigned to wallet with given invoice key""" + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + services = [] + for wallet_id in wallet_ids: + new_services = await get_services(wallet_id) + services += new_services if new_services else [] + return [service.dict() for service in services] if services else [] + + +@streamalerts_ext.get("/api/v1/donations") +async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)): + """Return list of all donations assigned to wallet with given invoice + key + """ + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + donations = [] + for wallet_id in wallet_ids: + new_donations = await get_donations(wallet_id) + donations += new_donations if new_donations else [] + return [donation._asdict() for donation in donations] if donations else [] + + +@streamalerts_ext.put("/api/v1/donations/{donation_id}") +async def api_update_donation(data: CreateDonation, donation_id=None, g: WalletTypeInfo = Depends(get_key_type)): + """Update a donation with the data given in the request""" + if donation_id: + donation = await get_donation(donation_id) + + if not donation: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Donation does not exist." + ) + + + if donation.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not your donation." + ) + + donation = await update_donation(donation_id, **data.dict()) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No donation ID specified" + ) + + return donation.dict() + + +@streamalerts_ext.put("/api/v1/services/{service_id}") +async def api_update_service(data: CreateService, service_id=None, g: WalletTypeInfo = Depends(get_key_type)): + """Update a service with the data given in the request""" + if service_id: + service = await get_service(service_id) + + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Service does not exist." + ) + + if service.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not your service." + ) + + service = await update_service(service_id, **data.dict()) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No service ID specified" + ) + return service.dict() + + +@streamalerts_ext.delete("/api/v1/donations/{donation_id}") +async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_type)): + """Delete the donation with the given donation_id""" + donation = await get_donation(donation_id) + if not donation: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No donation with this ID!" + ) + if donation.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this donation!" + ) + await delete_donation(donation_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@streamalerts_ext.delete("/api/v1/services/{service_id}") +async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_type)): + """Delete the service with the given service_id""" + service = await get_service(service_id) + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No service with this ID!" + ) + if service.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this service!" + ) + await delete_service(service_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT)