Merge pull request #227 from Fittiboy/TwitchAlerts
Stream Alerts extension
This commit is contained in:
commit
32764d1bad
39
lnbits/extensions/streamalerts/README.md
Normal file
39
lnbits/extensions/streamalerts/README.md
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<h1>Stream Alerts</h1>
|
||||||
|
<h2>Integrate Bitcoin Donations into your livestream alerts</h2>
|
||||||
|
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)
|
||||||
|
|
||||||
|
<h2>How to set it up</h2>
|
||||||
|
|
||||||
|
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).
|
||||||
|
<h3>CONGRATS! Let the sats flow!</h3>
|
11
lnbits/extensions/streamalerts/__init__.py
Normal file
11
lnbits/extensions/streamalerts/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from quart import Blueprint
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("ext_streamalerts")
|
||||||
|
|
||||||
|
streamalerts_ext: Blueprint = Blueprint(
|
||||||
|
"streamalerts", __name__, static_folder="static", template_folder="templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
6
lnbits/extensions/streamalerts/config.json
Normal file
6
lnbits/extensions/streamalerts/config.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Stream Alerts",
|
||||||
|
"short_description": "Integrate Bitcoin donations into your stream alerts!",
|
||||||
|
"icon": "notifications_active",
|
||||||
|
"contributors": ["Fittiboy"]
|
||||||
|
}
|
261
lnbits/extensions/streamalerts/crud.py
Normal file
261
lnbits/extensions/streamalerts/crud.py
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
from . import db
|
||||||
|
from .models import Donation, Service
|
||||||
|
|
||||||
|
from ..satspay.crud import delete_charge
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from quart import jsonify
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
|
||||||
|
|
||||||
|
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 Donations (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
cur_code,
|
||||||
|
sats,
|
||||||
|
amount,
|
||||||
|
service,
|
||||||
|
posted
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(id, wallet, name, message, cur_code, sats, amount, service, posted),
|
||||||
|
)
|
||||||
|
return await get_donation(id)
|
||||||
|
|
||||||
|
|
||||||
|
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 (jsonify({"message": "Donation not found!"}), HTTPStatus.BAD_REQUEST)
|
||||||
|
if donation.posted:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Donation has already been posted!"}),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
service = await get_service(donation.service)
|
||||||
|
if service.servicename == "Streamlabs":
|
||||||
|
url = "https://streamlabs.com/api/v1.0/donations"
|
||||||
|
data = {
|
||||||
|
"name": donation.name,
|
||||||
|
"message": donation.message,
|
||||||
|
"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 (
|
||||||
|
jsonify({"message": "StreamElements not yet supported!"}),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (jsonify({"message": "Unsopported servicename"}), HTTPStatus.BAD_REQUEST)
|
||||||
|
await db.execute("UPDATE Donations SET posted = 1 WHERE id = ?", (donation_id,))
|
||||||
|
return (jsonify(response.json()), status)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_service(
|
||||||
|
twitchuser: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
wallet: str,
|
||||||
|
servicename: str,
|
||||||
|
state: str = None,
|
||||||
|
onchain: str = None,
|
||||||
|
) -> Service:
|
||||||
|
"""Create a new Service"""
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO Services (
|
||||||
|
twitchuser,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
wallet,
|
||||||
|
servicename,
|
||||||
|
authenticated,
|
||||||
|
state,
|
||||||
|
onchain
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
twitchuser,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
wallet,
|
||||||
|
servicename,
|
||||||
|
False,
|
||||||
|
urlsafe_short_hash(),
|
||||||
|
onchain,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service_id = result._result_proxy.lastrowid
|
||||||
|
service = await get_service(service_id)
|
||||||
|
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 Services WHERE state = ?", (by_state,))
|
||||||
|
else:
|
||||||
|
row = await db.fetchone("SELECT * FROM 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 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 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 Services WHERE id = ?", (service_id,))
|
||||||
|
rows = await db.fetchall("SELECT * FROM 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 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 Donations assigned to wallet_id"""
|
||||||
|
rows = await db.fetchall("SELECT * FROM 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 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 Donations SET {q} WHERE id = ?", (*kwargs.values(), donation_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone("SELECT * FROM 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) -> Donation:
|
||||||
|
"""Update a service"""
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE Services SET {q} WHERE id = ?", (*kwargs.values(), service_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone("SELECT * FROM Services WHERE id = ?", (service_id,))
|
||||||
|
assert row, "Newly updated service couldn't be retrieved"
|
||||||
|
return Service(**row)
|
35
lnbits/extensions/streamalerts/migrations.py
Normal file
35
lnbits/extensions/streamalerts/migrations.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Services (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 Services(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
44
lnbits/extensions/streamalerts/models.py
Normal file
44
lnbits/extensions/streamalerts/models.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Donation(NamedTuple):
|
||||||
|
"""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(NamedTuple):
|
||||||
|
"""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: 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))
|
|
@ -0,0 +1,18 @@
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h4 class="text-subtitle1 q-my-none">
|
||||||
|
Stream Alerts: Integrate Bitcoin into your stream alerts!
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
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!<br />
|
||||||
|
For detailed setup instructions, check out
|
||||||
|
<a href="https://github.com/Fittiboy/bitcoin-on-twitch"> this guide!</a
|
||||||
|
><br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/Fittiboy">Fitti</a></small
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
|
@ -0,0 +1,94 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h5 class="q-my-none">Donate Bitcoin to {{ twitchuser }}</h5>
|
||||||
|
<br />
|
||||||
|
<q-form @submit="Invoice()" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="donationDialog.data.name"
|
||||||
|
type="name"
|
||||||
|
label="Your Name (leave blank for Anonymous donation)"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="donationDialog.data.sats"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
suffix="sats"
|
||||||
|
:rules="[val => val > 0 || 'Choose a positive number of sats!']"
|
||||||
|
label="Amount of sats"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="donationDialog.data.message"
|
||||||
|
type="textarea"
|
||||||
|
label="Donation Message (you can leave this blank too)"
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="donationDialog.data.sats < 1 || !donationDialog.data.sats"
|
||||||
|
type="submit"
|
||||||
|
>Submit</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
paymentReq: null,
|
||||||
|
redirectUrl: null,
|
||||||
|
donationDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
name: '',
|
||||||
|
sats: '',
|
||||||
|
message: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receive: {
|
||||||
|
show: false,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
Invoice: function () {
|
||||||
|
var self = this
|
||||||
|
axios
|
||||||
|
.post('/streamalerts/api/v1/donations', {
|
||||||
|
service: {{ service }},
|
||||||
|
name: self.donationDialog.data.name,
|
||||||
|
sats: self.donationDialog.data.sats,
|
||||||
|
message: self.donationDialog.data.message
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
self.redirect_url = response.data.redirect_url
|
||||||
|
console.log(self.redirect_url)
|
||||||
|
window.location.href = self.redirect_url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
506
lnbits/extensions/streamalerts/templates/streamalerts/index.html
Normal file
506
lnbits/extensions/streamalerts/templates/streamalerts/index.html
Normal file
|
@ -0,0 +1,506 @@
|
||||||
|
{% 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="deep-purple" @click="serviceDialog.show = true"
|
||||||
|
>New Service</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">Services</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportservicesCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="services"
|
||||||
|
row-key="id"
|
||||||
|
:columns="servicesTable.columns"
|
||||||
|
:pagination.sync="servicesTable.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="link"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="props.row.authUrl"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="send"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-8' : 'grey-6'"
|
||||||
|
type="a"
|
||||||
|
:href="props.row.displayUrl"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
<a :href="props.row.redirectURI">Redirect URI for Streamlabs</a>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteService(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</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">Donations</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportdonationsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="donations"
|
||||||
|
row-key="id"
|
||||||
|
:columns="donationsTable.columns"
|
||||||
|
:pagination.sync="donationsTable.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-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props" v-if="props.row.paid">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="email"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'mailto:' + props.row.email"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteDonation(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-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits Stream Alerts extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "streamalerts/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="serviceDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="sendServiceData" class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="serviceDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div v-if="walletLinks.length > 0">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="serviceDialog.data.chain"
|
||||||
|
label="Chain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-checkbox :value="false" label="Chain" disabled>
|
||||||
|
<q-tooltip>
|
||||||
|
Watch-Only extension MUST be activated and have a wallet
|
||||||
|
</q-tooltip>
|
||||||
|
</q-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="serviceDialog.data.chain">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="serviceDialog.data.onchain"
|
||||||
|
:options="walletLinks"
|
||||||
|
label="Chain Wallet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="serviceDialog.data.twitchuser"
|
||||||
|
type="name"
|
||||||
|
label="Twitch Username *"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="serviceDialog.data.servicename"
|
||||||
|
:options="servicenames"
|
||||||
|
label="Streamlabs"
|
||||||
|
hint="The service you use for alerts. (Currently only Streamlabs)"
|
||||||
|
></q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="serviceDialog.data.client_id"
|
||||||
|
type="name"
|
||||||
|
label="Client ID *"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="serviceDialog.data.client_secret"
|
||||||
|
type="name"
|
||||||
|
label="Client Secret *"
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="serviceDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
type="submit"
|
||||||
|
>Update Service</q-btn
|
||||||
|
>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="serviceDialog.data.client_id == null || serviceDialog.data.client_secret == 0 || serviceDialog.data.twitchuser == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Service</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 mapStreamAlerts = 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.redirectURI = ['/streamalerts/api/v1/authenticate/', obj.id].join('')
|
||||||
|
obj.authUrl = ['/streamalerts/api/v1/getaccess/', obj.id].join('')
|
||||||
|
obj.displayUrl = ['/streamalerts/', obj.state].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
servicenames: ['Streamlabs'],
|
||||||
|
services: [],
|
||||||
|
donations: [],
|
||||||
|
walletLinks: [],
|
||||||
|
servicesTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'twitchuser',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Twitch Username',
|
||||||
|
field: 'twitchuser'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wallet',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Wallet',
|
||||||
|
field: 'wallet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onchain address',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Onchain Address',
|
||||||
|
field: 'onchain'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'servicename',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Service',
|
||||||
|
field: 'servicename'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'client_id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Client ID',
|
||||||
|
field: 'client_id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'client_secret',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Client Secret',
|
||||||
|
field: 'client_secret'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'authenticated',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Authenticated',
|
||||||
|
field: 'authenticated'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
donationsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'service',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Service',
|
||||||
|
field: 'service'
|
||||||
|
},
|
||||||
|
{name: 'donor', align: 'left', label: 'Donor', field: 'donor'},
|
||||||
|
{name: 'ltext', align: 'left', label: 'Message', field: 'ltext'},
|
||||||
|
{name: 'sats', align: 'left', label: 'Sats', field: 'sats'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serviceDialog: {
|
||||||
|
show: false,
|
||||||
|
chain: false,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getWalletLinks: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/wallet',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
for (i = 0; i < response.data.length; i++) {
|
||||||
|
self.walletLinks.push(response.data[i].id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getDonations: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/streamalerts/api/v1/donations',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.donations = response.data.map(function (obj) {
|
||||||
|
return mapStreamAlerts(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteDonation: function (donationId) {
|
||||||
|
var self = this
|
||||||
|
var donations = _.findWhere(this.donations, {id: donationId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this donation?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/streamalerts/api/v1/donations/' + donationId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: donations.wallet}).inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.donations = _.reject(self.donations, function (obj) {
|
||||||
|
return obj.id == ticketId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportdonationsCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.donationsTable.columns, this.donations)
|
||||||
|
},
|
||||||
|
|
||||||
|
getServices: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/streamalerts/api/v1/services',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.services = response.data.map(function (obj) {
|
||||||
|
return mapStreamAlerts(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendServiceData: function () {
|
||||||
|
var wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.serviceDialog.data.wallet
|
||||||
|
})
|
||||||
|
var data = this.serviceDialog.data
|
||||||
|
|
||||||
|
this.createService(wallet, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
createService: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/streamalerts/api/v1/services', wallet.inkey, data)
|
||||||
|
.then(function (response) {
|
||||||
|
self.services.push(mapStreamAlerts(response.data))
|
||||||
|
self.serviceDialog.show = false
|
||||||
|
self.serviceDialog.data = {}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateserviceDialog: function (serviceId) {
|
||||||
|
var link = _.findWhere(this.services, {id: serviceId})
|
||||||
|
console.log(link.id)
|
||||||
|
this.serviceDialog.data.id = link.id
|
||||||
|
this.serviceDialog.data.wallet = link.wallet
|
||||||
|
this.serviceDialog.data.twitchuser = link.twitchuser
|
||||||
|
this.serviceDialog.data.servicename = link.servicename
|
||||||
|
this.serviceDialog.data.client_id = link.client_id
|
||||||
|
this.serviceDialog.data.client_secret = link.client_secret
|
||||||
|
this.serviceDialog.show = true
|
||||||
|
},
|
||||||
|
deleteService: function (servicesId) {
|
||||||
|
var self = this
|
||||||
|
var services = _.findWhere(this.services, {id: servicesId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this service link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/streamalerts/api/v1/services/' + servicesId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: services.wallet}).inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.services = _.reject(self.services, function (obj) {
|
||||||
|
return obj.id == servicesId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportservicesCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.servicesTable.columns, this.services)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getWalletLinks()
|
||||||
|
this.getDonations()
|
||||||
|
this.getServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
28
lnbits/extensions/streamalerts/views.py
Normal file
28
lnbits/extensions/streamalerts/views.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from quart import g, abort, render_template
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from . import streamalerts_ext
|
||||||
|
from .crud import get_service
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
async def index():
|
||||||
|
"""Return the extension's settings page"""
|
||||||
|
return await render_template("streamalerts/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/<state>")
|
||||||
|
async def donation(state):
|
||||||
|
"""Return the donation form for the Service corresponding to state"""
|
||||||
|
service = await get_service(0, by_state=state)
|
||||||
|
if not service:
|
||||||
|
abort(HTTPStatus.NOT_FOUND, "Service does not exist.")
|
||||||
|
return await render_template(
|
||||||
|
"streamalerts/display.html",
|
||||||
|
twitchuser=service.twitchuser,
|
||||||
|
service=service.id
|
||||||
|
)
|
271
lnbits/extensions/streamalerts/views_api.py
Normal file
271
lnbits/extensions/streamalerts/views_api.py
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
from quart import g, redirect, request, jsonify
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.decorators import api_validate_post_request, api_check_wallet_key
|
||||||
|
from lnbits.core.crud import get_wallet, get_user
|
||||||
|
from lnbits.utils.exchange_rates import btc_price
|
||||||
|
|
||||||
|
from . import streamalerts_ext
|
||||||
|
from .crud import (
|
||||||
|
get_charge_details,
|
||||||
|
get_service_redirect_uri,
|
||||||
|
create_donation,
|
||||||
|
post_donation,
|
||||||
|
get_donation,
|
||||||
|
get_donations,
|
||||||
|
delete_donation,
|
||||||
|
create_service,
|
||||||
|
get_service,
|
||||||
|
get_services,
|
||||||
|
authenticate_service,
|
||||||
|
update_donation,
|
||||||
|
update_service,
|
||||||
|
delete_service,
|
||||||
|
)
|
||||||
|
from ..satspay.crud import create_charge, get_charge
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/services", methods=["POST"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"twitchuser": {"type": "string", "required": True},
|
||||||
|
"client_id": {"type": "string", "required": True},
|
||||||
|
"client_secret": {"type": "string", "required": True},
|
||||||
|
"wallet": {"type": "string", "required": True},
|
||||||
|
"servicename": {"type": "string", "required": True},
|
||||||
|
"onchain": {"type": "string"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_create_service():
|
||||||
|
"""Create a service, which holds data about how/where to post donations"""
|
||||||
|
service = await create_service(**g.data)
|
||||||
|
wallet = await get_wallet(service.wallet)
|
||||||
|
user = wallet.user
|
||||||
|
redirect_url = request.scheme + "://" + request.headers["Host"]
|
||||||
|
redirect_url += f"/streamalerts/?usr={user}&created={str(service.id)}"
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/getaccess/<service_id>", methods=["GET"])
|
||||||
|
async def api_get_access(service_id):
|
||||||
|
"""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 redirect(redirect_url)
|
||||||
|
else:
|
||||||
|
return (jsonify({"message": "Service does not exist!"}), HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/authenticate/<service_id>", methods=["GET"])
|
||||||
|
async def api_authenticate_service(service_id):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
code = request.args.get("code")
|
||||||
|
state = request.args.get("state")
|
||||||
|
service = await get_service(service_id)
|
||||||
|
if service.state != state:
|
||||||
|
return (jsonify({"message": "State doesn't match!"}), HTTPStatus.BAD_Request)
|
||||||
|
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 redirect(url)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Service already authenticated!"}),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/donations", methods=["POST"])
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"sats": {"type": "integer", "required": True},
|
||||||
|
"service": {"type": "integer", "required": True},
|
||||||
|
"message": {"type": "string"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_create_donation():
|
||||||
|
"""Take data from donation form and return satspay charge"""
|
||||||
|
# Currency is hardcoded while frotnend is limited
|
||||||
|
cur_code = "USD"
|
||||||
|
sats = g.data["sats"]
|
||||||
|
message = g.data.get("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 = g.data["service"]
|
||||||
|
service = await get_service(service_id)
|
||||||
|
charge_details = await get_charge_details(service.id)
|
||||||
|
name = g.data.get("name", "Anonymous")
|
||||||
|
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=g.data["sats"],
|
||||||
|
amount=amount,
|
||||||
|
service=g.data["service"],
|
||||||
|
)
|
||||||
|
return (jsonify({"redirect_url": f"/satspay/{charge.id}"}), HTTPStatus.OK)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/postdonation", methods=["POST"])
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"id": {"type": "string", "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_post_donation():
|
||||||
|
"""Post a paid donation to Stremalabs/StreamElements.
|
||||||
|
|
||||||
|
This endpoint acts as a webhook for the SatsPayServer extension."""
|
||||||
|
data = await request.get_json(force=True)
|
||||||
|
donation_id = data.get("id", "No ID")
|
||||||
|
charge = await get_charge(donation_id)
|
||||||
|
if charge and charge.paid:
|
||||||
|
return await post_donation(donation_id)
|
||||||
|
else:
|
||||||
|
return (jsonify({"message": "Not a paid charge!"}), HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/services", methods=["GET"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_get_services():
|
||||||
|
"""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 (
|
||||||
|
jsonify([service._asdict() for service in services] if services else []),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/donations", methods=["GET"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_get_donations():
|
||||||
|
"""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 (
|
||||||
|
jsonify([donation._asdict() for donation in donations] if donations else []),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/donations/<donation_id>", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_update_donation(donation_id=None):
|
||||||
|
"""Update a donation with the data given in the request"""
|
||||||
|
if donation_id:
|
||||||
|
donation = await get_donation(donation_id)
|
||||||
|
|
||||||
|
if not donation:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Donation does not exist."}),
|
||||||
|
HTTPStatus.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if donation.wallet != g.wallet.id:
|
||||||
|
return (jsonify({"message": "Not your donation."}), HTTPStatus.FORBIDDEN)
|
||||||
|
|
||||||
|
donation = await update_donation(donation_id, **g.data)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "No donation ID specified"}),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
return jsonify(donation._asdict()), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/services/<service_id>", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_update_service(service_id=None):
|
||||||
|
"""Update a service with the data given in the request"""
|
||||||
|
if service_id:
|
||||||
|
service = await get_service(service_id)
|
||||||
|
|
||||||
|
if not service:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Service does not exist."}),
|
||||||
|
HTTPStatus.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if service.wallet != g.wallet.id:
|
||||||
|
return (jsonify({"message": "Not your service."}), HTTPStatus.FORBIDDEN)
|
||||||
|
|
||||||
|
service = await update_service(service_id, **g.data)
|
||||||
|
else:
|
||||||
|
return (jsonify({"message": "No service ID specified"}), HTTPStatus.BAD_REQUEST)
|
||||||
|
return jsonify(service._asdict()), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/donations/<donation_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_delete_donation(donation_id):
|
||||||
|
"""Delete the donation with the given donation_id"""
|
||||||
|
donation = await get_donation(donation_id)
|
||||||
|
if not donation:
|
||||||
|
return (jsonify({"message": "No donation with this ID!"}), HTTPStatus.NOT_FOUND)
|
||||||
|
if donation.wallet != g.wallet.id:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Not authorized to delete this donation!"}),
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
)
|
||||||
|
await delete_donation(donation_id)
|
||||||
|
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
@streamalerts_ext.route("/api/v1/services/<service_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_delete_service(service_id):
|
||||||
|
"""Delete the service with the given service_id"""
|
||||||
|
service = await get_service(service_id)
|
||||||
|
if not service:
|
||||||
|
return (jsonify({"message": "No service with this ID!"}), HTTPStatus.NOT_FOUND)
|
||||||
|
if service.wallet != g.wallet.id:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "Not authorized to delete this service!"}),
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
)
|
||||||
|
await delete_service(service_id)
|
||||||
|
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
Loading…
Reference in New Issue
Block a user