Rename TwitchAlerts to StreamAlerts
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
The StreamAlerts extension allows you to integrate Bitcoin Lightning (and on-chain) paymnents in to your existing Streamlabs alerts!
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"StreamAlerts"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Stream Alerts",
|
||||
"short_description": "Integrate Bitcoin donations into your stream alerts!",
|
||||
"icon": "notifications_active",
|
||||
"contributors": ["Fittiboy"]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,439 @@
|
||||
{% 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>
|
||||
</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>
|
||||
<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.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: [],
|
||||
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: '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,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
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.getDonations()
|
||||
this.getServices()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user