Merge pull request #227 from Fittiboy/TwitchAlerts

Stream Alerts extension
This commit is contained in:
fiatjaf 2021-08-02 05:23:25 -03:00 committed by GitHub
commit 32764d1bad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1313 additions and 0 deletions

View 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>

View 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

View File

@ -0,0 +1,6 @@
{
"name": "Stream Alerts",
"short_description": "Integrate Bitcoin donations into your stream alerts!",
"icon": "notifications_active",
"contributors": ["Fittiboy"]
}

View 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)

View 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)
);
"""
)

View 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))

View File

@ -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>

View File

@ -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 %}

View 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 %}

View 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
)

View 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