Skip to content

Commit

Permalink
init commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dni committed Feb 15, 2023
0 parents commit 0857065
Show file tree
Hide file tree
Showing 15 changed files with 1,419 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: release github version
on:
push:
tags:
- "[0-9]+.[0-9]+"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 Onchain Wallet (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>
25 changes: 25 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles

from lnbits.db import Database
from lnbits.helpers import template_renderer

db = Database("ext_streamalerts")

streamalerts_ext: APIRouter = APIRouter(prefix="/streamalerts", tags=["streamalerts"])

streamalerts_static_files = [
{
"path": "/streamalerts/static",
"app": StaticFiles(directory="lnbits/extensions/streamalerts/static"),
"name": "streamalerts_static",
}
]


def streamalerts_renderer():
return template_renderer(["lnbits/extensions/streamalerts/templates"])


from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
6 changes: 6 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Stream Alerts",
"short_description": "Bitcoin donations in stream alerts",
"tile": "/streamalerts/static/image/streamalerts.png",
"contributors": ["Fittiboy"]
}
284 changes: 284 additions & 0 deletions crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
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

# todo: use the API, not direct import
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.url.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.
"""
service = await get_service(service_id)
assert service, f"Could not fetch service: {service_id}"

wallet_id = service.wallet
wallet = await get_wallet(wallet_id)
assert wallet, f"Could not fetch wallet: {wallet_id}"

user = wallet.user
return {
"time": 1440,
"user": user,
"lnbitswallet": wallet_id,
"onchainwallet": service.onchain,
}


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) -> dict:
"""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)
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] # type: ignore

service = await get_service(service_id)
assert service, f"Could not fetch service: {service_id}"
return service


async def get_service(
service_id: int, by_state: Optional[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)
assert service, f"Could not fetch service: {service_id}"
wallet = await get_wallet(service.wallet)
assert wallet, f"Could not fetch 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,
}
async with httpx.AsyncClient() as client:
response = (await client.post(url, data=data)).json()
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.
"""
service = await get_service(service_id)
assert service, f"Could not fetch service: {service_id}"
if service.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)
9 changes: 9 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"repos": [
{
"id": "streamalerts",
"organisation": "lnbits",
"repository": "streamalerts"
}
]
}
Loading

0 comments on commit 0857065

Please sign in to comment.