streamalerts converted untested

This commit is contained in:
Tiago vasconcelos 2021-10-29 12:44:45 +01:00
parent 6278e5357c
commit cfe7fc5e58
11 changed files with 1370 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,17 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_streamalerts")
streamalerts_ext: APIRouter = APIRouter(
prefix="/streamalerts",
tags=["streamalerts"]
)
def streamalerts_renderer():
return template_renderer(["lnbits/extensions/streamalerts/templates"])
from .views import * # noqa
from .views_api import * # noqa

View File

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

View File

@ -0,0 +1,283 @@
from http import HTTPStatus
from typing import Optional
import httpx
from lnbits.core.crud import get_wallet
from lnbits.db import SQLITE
from lnbits.helpers import urlsafe_short_hash
from ..satspay.crud import delete_charge # type: ignore
from . import db
from .models import CreateService, Donation, Service
async def get_service_redirect_uri(request, service_id):
"""Return the service's redirect URI, to be given to the third party API"""
uri_base = request.scheme + "://"
uri_base += request.headers["Host"] + "/streamalerts/api/v1"
redirect_uri = uri_base + f"/authenticate/{service_id}"
return redirect_uri
async def get_charge_details(service_id):
"""Return the default details for a satspay charge
These might be different depending for services implemented in the future.
"""
details = {
"time": 1440,
}
service = await get_service(service_id)
wallet_id = service.wallet
wallet = await get_wallet(wallet_id)
user = wallet.user
details["user"] = user
details["lnbitswallet"] = wallet_id
details["onchainwallet"] = service.onchain
return details
async def create_donation(
id: str,
wallet: str,
cur_code: str,
sats: int,
amount: float,
service: int,
name: str = "Anonymous",
message: str = "",
posted: bool = False,
) -> Donation:
"""Create a new Donation"""
await db.execute(
"""
INSERT INTO streamalerts.Donations (
id,
wallet,
name,
message,
cur_code,
sats,
amount,
service,
posted
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(id, wallet, name, message, cur_code, sats, amount, service, posted),
)
donation = await get_donation(id)
assert donation, "Newly created donation couldn't be retrieved"
return donation
async def post_donation(donation_id: str) -> tuple:
"""Post donations to their respective third party APIs
If the donation has already been posted, it will not be posted again.
"""
donation = await get_donation(donation_id)
if not donation:
return {"message": "Donation not found!"}
if donation.posted:
return {"message": "Donation has already been posted!"}
service = await get_service(donation.service)
assert service, "Couldn't fetch service to donate to"
if service.servicename == "Streamlabs":
url = "https://streamlabs.com/api/v1.0/donations"
data = {
"name": donation.name[:25],
"message": donation.message[:255],
"identifier": "LNbits",
"amount": donation.amount,
"currency": donation.cur_code.upper(),
"access_token": service.token,
}
async with httpx.AsyncClient() as client:
response = await client.post(url, data=data)
print(response.json())
status = [s for s in list(HTTPStatus) if s == response.status_code][0]
elif service.servicename == "StreamElements":
return {"message": "StreamElements not yet supported!"}
else:
return {"message": "Unsopported servicename"}
await db.execute(
"UPDATE streamalerts.Donations SET posted = 1 WHERE id = ?", (donation_id,)
)
return response.json()
async def create_service(
data: CreateService
) -> Service:
"""Create a new Service"""
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await (method)(
f"""
INSERT INTO streamalerts.Services (
twitchuser,
client_id,
client_secret,
wallet,
servicename,
authenticated,
state,
onchain
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
{returning}
""",
(
data.twitchuser,
data.client_id,
data.client_secret,
data.wallet,
data.servicename,
False,
urlsafe_short_hash(),
data.onchain,
),
)
if db.type == SQLITE:
service_id = result._result_proxy.lastrowid
else:
service_id = result[0]
service = await get_service(service_id)
assert service
return service
async def get_service(service_id: int, by_state: str = None) -> Optional[Service]:
"""Return a service either by ID or, available, by state
Each Service's donation page is reached through its "state" hash
instead of the ID, preventing accidental payments to the wrong
streamer via typos like 2 -> 3.
"""
if by_state:
row = await db.fetchone(
"SELECT * FROM streamalerts.Services WHERE state = ?", (by_state,)
)
else:
row = await db.fetchone(
"SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,)
)
return Service.from_row(row) if row else None
async def get_services(wallet_id: str) -> Optional[list]:
"""Return all services belonging assigned to the wallet_id"""
rows = await db.fetchall(
"SELECT * FROM streamalerts.Services WHERE wallet = ?", (wallet_id,)
)
return [Service.from_row(row) for row in rows] if rows else None
async def authenticate_service(service_id, code, redirect_uri):
"""Use authentication code from third party API to retreive access token"""
# The API token is passed in the querystring as 'code'
service = await get_service(service_id)
wallet = await get_wallet(service.wallet)
user = wallet.user
url = "https://streamlabs.com/api/v1.0/token"
data = {
"grant_type": "authorization_code",
"code": code,
"client_id": service.client_id,
"client_secret": service.client_secret,
"redirect_uri": redirect_uri,
}
print(data)
async with httpx.AsyncClient() as client:
response = (await client.post(url, data=data)).json()
print(response)
token = response["access_token"]
success = await service_add_token(service_id, token)
return f"/streamalerts/?usr={user}", success
async def service_add_token(service_id, token):
"""Add access token to its corresponding Service
This also sets authenticated = 1 to make sure the token
is not overwritten.
Tokens for Streamlabs never need to be refreshed.
"""
if (await get_service(service_id)).authenticated:
return False
await db.execute(
"UPDATE streamalerts.Services SET authenticated = 1, token = ? where id = ?",
(
token,
service_id,
),
)
return True
async def delete_service(service_id: int) -> None:
"""Delete a Service and all corresponding Donations"""
await db.execute("DELETE FROM streamalerts.Services WHERE id = ?", (service_id,))
rows = await db.fetchall(
"SELECT * FROM streamalerts.Donations WHERE service = ?", (service_id,)
)
for row in rows:
await delete_donation(row["id"])
async def get_donation(donation_id: str) -> Optional[Donation]:
"""Return a Donation"""
row = await db.fetchone(
"SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,)
)
return Donation.from_row(row) if row else None
async def get_donations(wallet_id: str) -> Optional[list]:
"""Return all streamalerts.Donations assigned to wallet_id"""
rows = await db.fetchall(
"SELECT * FROM streamalerts.Donations WHERE wallet = ?", (wallet_id,)
)
return [Donation.from_row(row) for row in rows] if rows else None
async def delete_donation(donation_id: str) -> None:
"""Delete a Donation and its corresponding statspay charge"""
await db.execute("DELETE FROM streamalerts.Donations WHERE id = ?", (donation_id,))
await delete_charge(donation_id)
async def update_donation(donation_id: str, **kwargs) -> Donation:
"""Update a Donation"""
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE streamalerts.Donations SET {q} WHERE id = ?",
(*kwargs.values(), donation_id),
)
row = await db.fetchone(
"SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,)
)
assert row, "Newly updated donation couldn't be retrieved"
return Donation(**row)
async def update_service(service_id: str, **kwargs) -> Service:
"""Update a service"""
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE streamalerts.Services SET {q} WHERE id = ?",
(*kwargs.values(), service_id),
)
row = await db.fetchone(
"SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,)
)
assert row, "Newly updated service couldn't be retrieved"
return Service(**row)

View File

@ -0,0 +1,35 @@
async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS streamalerts.Services (
id {db.serial_primary_key},
state TEXT NOT NULL,
twitchuser TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
wallet TEXT NOT NULL,
onchain TEXT,
servicename TEXT NOT NULL,
authenticated BOOLEAN NOT NULL,
token TEXT
);
"""
)
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS streamalerts.Donations (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
message TEXT NOT NULL,
cur_code TEXT NOT NULL,
sats INT NOT NULL,
amount FLOAT NOT NULL,
service INTEGER NOT NULL,
posted BOOLEAN NOT NULL,
FOREIGN KEY(service) REFERENCES {db.references_schema}Services(id)
);
"""
)

View File

@ -0,0 +1,65 @@
from sqlite3 import Row
from typing import Optional
from fastapi.params import Query
from pydantic.main import BaseModel
class CreateService(BaseModel):
twitchuser: str = Query(...)
client_id: str = Query(...)
client_secret: str = Query(...)
wallet: str = Query(...)
servicename: str = Query(...)
onchain: str = Query(None)
class CreateDonation(BaseModel):
name: str = Query("Anonymous")
sats: int = Query(..., ge=1)
service: int = Query(...)
message: str = Query("")
class ValidateDonation(BaseModel):
id: str = Query(...)
class Donation(BaseModel):
"""A Donation simply contains all the necessary information about a
user's donation to a streamer
"""
id: str # This ID always corresponds to a satspay charge ID
wallet: str
name: str # Name of the donor
message: str # Donation message
cur_code: str # Three letter currency code accepted by Streamlabs
sats: int
amount: float # The donation amount after fiat conversion
service: int # The ID of the corresponding Service
posted: bool # Whether the donation has already been posted to a Service
@classmethod
def from_row(cls, row: Row) -> "Donation":
return cls(**dict(row))
class Service(BaseModel):
"""A Service represents an integration with a third-party API
Currently, Streamlabs is the only supported Service.
"""
id: int
state: str # A random hash used during authentication
twitchuser: str # The Twitch streamer's username
client_id: str # Third party service Client ID
client_secret: str # Secret corresponding to the Client ID
wallet: str
onchain: Optional[str]
servicename: str # Currently, this will just always be "Streamlabs"
authenticated: bool # Whether a token (see below) has been acquired yet
token: Optional[int] # The token with which to authenticate requests
@classmethod
def from_row(cls, row: Row) -> "Service":
return cls(**dict(row))

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,97 @@
{% 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"
maxlength="25"
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"
max="2100000000000000"
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"
maxlength="255"
type="textarea"
label="Donation Message (you can leave this blank too)"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
: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,502 @@
{% 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="primary" @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"
:columns="donationsTable.columns"
:pagination.sync="donationsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<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">
<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">
{{SITE_TITLE}} 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="primary"
type="submit"
>Update Service</q-btn
>
<q-btn
v-else
unelevated
color="primary"
: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: 'id', align: 'left', label: 'Charge ID', field: 'id'},
{name: 'name', align: 'left', label: 'Donor', field: 'name'},
{
name: 'message',
align: 'left',
label: 'Message',
field: 'message'
},
{name: 'sats', align: 'left', label: 'Sats', field: 'sats'},
{
name: 'posted',
align: 'left',
label: 'Posted to API',
field: 'posted'
}
],
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,39 @@
from http import HTTPStatus
from fastapi.param_functions import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import streamalerts_ext, streamalerts_renderer
from .crud import get_service
templates = Jinja2Templates(directory="templates")
@streamalerts_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
"""Return the extension's settings page"""
return streamalerts_renderer().TemplateResponse("streamalerts/index.html", {"request": request, "user": user.dict()})
@streamalerts_ext.get("/{state}")
async def donation(state, request: Request):
"""Return the donation form for the Service corresponding to state"""
service = await get_service(0, by_state=state)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist."
)
return streamalerts_renderer().TemplateResponse(
"streamalerts/display.html",
{
"request": request,
"twitchuser": service.twitchuser,
"service":service.id
}
)

View File

@ -0,0 +1,269 @@
from http import HTTPStatus
from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import RedirectResponse
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.streamalerts.models import (
CreateDonation,
CreateService,
ValidateDonation,
)
from lnbits.utils.exchange_rates import btc_price
from ..satspay.crud import create_charge, get_charge
from . import streamalerts_ext
from .crud import (
authenticate_service,
create_donation,
create_service,
delete_donation,
delete_service,
get_charge_details,
get_donation,
get_donations,
get_service,
get_service_redirect_uri,
get_services,
post_donation,
update_donation,
update_service,
)
@streamalerts_ext.post("/api/v1/services")
async def api_create_service(data : CreateService, wallet: WalletTypeInfo = Depends(get_key_type)):
"""Create a service, which holds data about how/where to post donations"""
try:
service = await create_service(data=data)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
)
return service.dict()
@streamalerts_ext.get("/api/v1/getaccess/{service_id}")
async def api_get_access(service_id, request: Request):
"""Redirect to Streamlabs' Approve/Decline page for API access for Service
with service_id
"""
service = await get_service(service_id)
if service:
redirect_uri = await get_service_redirect_uri(request, service_id)
params = {
"response_type": "code",
"client_id": service.client_id,
"redirect_uri": redirect_uri,
"scope": "donations.create",
"state": service.state,
}
endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?"
querystring = "&".join([f"{key}={value}" for key, value in params.items()])
redirect_url = endpoint_url + querystring
return RedirectResponse(redirect_url)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Service does not exist!"
)
@streamalerts_ext.get("/api/v1/authenticate/{service_id}")
async def api_authenticate_service(service_id, request: Request, code: str = Query(...), state: str = Query(...)):
"""Endpoint visited via redirect during third party API authentication
If successful, an API access token will be added to the service, and
the user will be redirected to index.html.
"""
service = await get_service(service_id)
if service.state != state:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="State doesn't match!"
)
redirect_uri = request.scheme + "://" + request.headers["Host"]
redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}"
url, success = await authenticate_service(service_id, code, redirect_uri)
if success:
return RedirectResponse(url)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Service already authenticated!"
)
@streamalerts_ext.post("/api/v1/donations")
async def api_create_donation(data: CreateDonation, request: Request):
"""Take data from donation form and return satspay charge"""
# Currency is hardcoded while frotnend is limited
cur_code = "USD"
sats = data.sats
message = data.message
# Fiat amount is calculated here while frontend is limited
price = await btc_price(cur_code)
amount = sats * (10 ** (-8)) * price
webhook_base = request.scheme + "://" + request.headers["Host"]
service_id = data.service
service = await get_service(service_id)
charge_details = await get_charge_details(service.id)
name = data.name
description = f"{sats} sats donation from {name} to {service.twitchuser}"
charge = await create_charge(
amount=sats,
completelink=f"https://twitch.tv/{service.twitchuser}",
completelinktext="Back to Stream!",
webhook=webhook_base + "/streamalerts/api/v1/postdonation",
description=description,
**charge_details,
)
await create_donation(
id=charge.id,
wallet=service.wallet,
message=message,
name=name,
cur_code=cur_code,
sats=data.sats,
amount=amount,
service=data.service
)
return {"redirect_url": f"/satspay/{charge.id}"}
@streamalerts_ext.post("/api/v1/postdonation")
async def api_post_donation(request: Request, data: ValidateDonation):
"""Post a paid donation to Stremalabs/StreamElements.
This endpoint acts as a webhook for the SatsPayServer extension."""
donation_id = data.id
charge = await get_charge(donation_id)
if charge and charge.paid:
return await post_donation(donation_id)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Not a paid charge!"
)
@streamalerts_ext.get("/api/v1/services")
async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)):
"""Return list of all services assigned to wallet with given invoice key"""
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
services = []
for wallet_id in wallet_ids:
new_services = await get_services(wallet_id)
services += new_services if new_services else []
return [service.dict() for service in services] if services else []
@streamalerts_ext.get("/api/v1/donations")
async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)):
"""Return list of all donations assigned to wallet with given invoice
key
"""
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
donations = []
for wallet_id in wallet_ids:
new_donations = await get_donations(wallet_id)
donations += new_donations if new_donations else []
return [donation._asdict() for donation in donations] if donations else []
@streamalerts_ext.put("/api/v1/donations/{donation_id}")
async def api_update_donation(data: CreateDonation, donation_id=None, g: WalletTypeInfo = Depends(get_key_type)):
"""Update a donation with the data given in the request"""
if donation_id:
donation = await get_donation(donation_id)
if not donation:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Donation does not exist."
)
if donation.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your donation."
)
donation = await update_donation(donation_id, **data.dict())
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No donation ID specified"
)
return donation.dict()
@streamalerts_ext.put("/api/v1/services/{service_id}")
async def api_update_service(data: CreateService, service_id=None, g: WalletTypeInfo = Depends(get_key_type)):
"""Update a service with the data given in the request"""
if service_id:
service = await get_service(service_id)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Service does not exist."
)
if service.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your service."
)
service = await update_service(service_id, **data.dict())
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No service ID specified"
)
return service.dict()
@streamalerts_ext.delete("/api/v1/donations/{donation_id}")
async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_type)):
"""Delete the donation with the given donation_id"""
donation = await get_donation(donation_id)
if not donation:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="No donation with this ID!"
)
if donation.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not authorized to delete this donation!"
)
await delete_donation(donation_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@streamalerts_ext.delete("/api/v1/services/{service_id}")
async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_type)):
"""Delete the service with the given service_id"""
service = await get_service(service_id)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="No service with this ID!"
)
if service.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not authorized to delete this service!"
)
await delete_service(service_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)