This commit is contained in:
Ben Arc 2021-10-12 19:54:35 +01:00
parent 567b8e3c48
commit c749b7d11e
21 changed files with 1872 additions and 0 deletions

View File

@ -0,0 +1,3 @@
# StreamerCopilot
Tool to help streamers accept sats for tips

View File

@ -0,0 +1,17 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_copilot")
copilot_ext: Blueprint = Blueprint(
"copilot", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
copilot_ext.record(record_async(register_listeners))

View File

@ -0,0 +1,8 @@
{
"name": "Streamer Copilot",
"short_description": "Video tips/animations/webhooks",
"icon": "face",
"contributors": [
"arcbtc"
]
}

View File

@ -0,0 +1,107 @@
from typing import List, Optional, Union
# from lnbits.db import open_ext_db
from . import db
from .models import Copilots
from lnbits.helpers import urlsafe_short_hash
from quart import jsonify
###############COPILOTS##########################
async def create_copilot(
title: str,
user: str,
lnurl_toggle: Optional[int] = 0,
wallet: Optional[str] = None,
animation1: Optional[str] = None,
animation2: Optional[str] = None,
animation3: Optional[str] = None,
animation1threshold: Optional[int] = None,
animation2threshold: Optional[int] = None,
animation3threshold: Optional[int] = None,
animation1webhook: Optional[str] = None,
animation2webhook: Optional[str] = None,
animation3webhook: Optional[str] = None,
lnurl_title: Optional[str] = None,
show_message: Optional[int] = 0,
show_ack: Optional[int] = 0,
show_price: Optional[str] = None,
amount_made: Optional[int] = None,
) -> Copilots:
copilot_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO copilot.copilots (
id,
"user",
lnurl_toggle,
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
show_message,
show_ack,
show_price,
amount_made
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
copilot_id,
user,
int(lnurl_toggle),
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
int(show_message),
int(show_ack),
show_price,
0,
),
)
return await get_copilot(copilot_id)
async def update_copilot(copilot_id: str, **kwargs) -> Optional[Copilots]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE copilot.copilots SET {q} WHERE id = ?", (*kwargs.values(), copilot_id)
)
row = await db.fetchone("SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,))
return Copilots.from_row(row) if row else None
async def get_copilot(copilot_id: str) -> Copilots:
row = await db.fetchone("SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,))
return Copilots.from_row(row) if row else None
async def get_copilots(user: str) -> List[Copilots]:
rows = await db.fetchall("""SELECT * FROM copilot.copilots WHERE "user" = ?""", (user,))
return [Copilots.from_row(row) for row in rows]
async def delete_copilot(copilot_id: str) -> None:
await db.execute("DELETE FROM copilot.copilots WHERE id = ?", (copilot_id,))

View File

@ -0,0 +1,86 @@
import json
import hashlib
import math
from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnurl.types import LnurlPayMetadata
from lnbits.core.services import create_invoice
from . import copilot_ext
from .crud import get_copilot
@copilot_ext.route("/lnurl/<cp_id>", methods=["GET"])
async def lnurl_response(cp_id):
cp = await get_copilot(cp_id)
if not cp:
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
resp = LnurlPayResponse(
callback=url_for("copilot.lnurl_callback", cp_id=cp_id, _external=True),
min_sendable=10000,
max_sendable=50000000,
metadata=LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
)
params = resp.dict()
if cp.show_message:
params["commentAllowed"] = 300
return jsonify(params)
@copilot_ext.route("/lnurl/cb/<cp_id>", methods=["GET"])
async def lnurl_callback(cp_id):
cp = await get_copilot(cp_id)
if not cp:
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
amount_received = int(request.args.get("amount"))
if amount_received < 10000:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum 10 sats."
).dict()
),
)
elif amount_received / 1000 > 10000000:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum 50000."
).dict()
),
)
comment = ""
if request.args.get("comment"):
comment = request.args.get("comment")
if len(comment or "") > 300:
return jsonify(
LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
).dict()
)
if len(comment) < 1:
comment = "none"
payment_hash, payment_request = await create_invoice(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=hashlib.sha256(
(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8")
).digest(),
extra={"tag": "copilot", "copilot": cp.id, "comment": comment},
)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=None,
disposable=False,
routes=[],
)
return jsonify(resp.dict())

View File

@ -0,0 +1,76 @@
async def m001_initial(db):
"""
Initial copilot table.
"""
await db.execute(
f"""
CREATE TABLE copilot.copilots (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
lnurl_toggle INTEGER,
wallet TEXT,
animation1 TEXT,
animation2 TEXT,
animation3 TEXT,
animation1threshold INTEGER,
animation2threshold INTEGER,
animation3threshold INTEGER,
animation1webhook TEXT,
animation2webhook TEXT,
animation3webhook TEXT,
lnurl_title TEXT,
show_message INTEGER,
show_ack INTEGER,
show_price INTEGER,
amount_made INTEGER,
fullscreen_cam INTEGER,
iframe_url TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m002_fix_data_types(db):
"""
Fix data types.
"""
if(db.type != "SQLITE"):
await db.execute("ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;")
# If needed, migration for SQLite (RENAME not working properly)
#
# await db.execute(
# f"""
# CREATE TABLE copilot.new_copilots (
# id TEXT NOT NULL PRIMARY KEY,
# "user" TEXT,
# title TEXT,
# lnurl_toggle INTEGER,
# wallet TEXT,
# animation1 TEXT,
# animation2 TEXT,
# animation3 TEXT,
# animation1threshold INTEGER,
# animation2threshold INTEGER,
# animation3threshold INTEGER,
# animation1webhook TEXT,
# animation2webhook TEXT,
# animation3webhook TEXT,
# lnurl_title TEXT,
# show_message INTEGER,
# show_ack INTEGER,
# show_price TEXT,
# amount_made INTEGER,
# fullscreen_cam INTEGER,
# iframe_url TEXT,
# timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
# );
# """
# )
#
# await db.execute("INSERT INTO copilot.new_copilots SELECT * FROM copilot.copilots;")
# await db.execute("DROP TABLE IF EXISTS copilot.copilots;")
# await db.execute("ALTER TABLE copilot.new_copilots RENAME TO copilot.copilots;")

View File

@ -0,0 +1,41 @@
from sqlite3 import Row
from typing import NamedTuple
import time
from quart import url_for
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
class Copilots(NamedTuple):
id: str
user: str
title: str
lnurl_toggle: int
wallet: str
animation1: str
animation2: str
animation3: str
animation1threshold: int
animation2threshold: int
animation3threshold: int
animation1webhook: str
animation2webhook: str
animation3webhook: str
lnurl_title: str
show_message: int
show_ack: int
show_price: int
amount_made: int
timestamp: int
fullscreen_cam: int
iframe_url: str
@classmethod
def from_row(cls, row: Row) -> "Copilots":
return cls(**dict(row))
@property
def lnurl(self) -> Lnurl:
url = url_for("copilot.lnurl_response", cp_id=self.id, _external=True)
return lnurl_encode(url)

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

View File

@ -0,0 +1,88 @@
import trio # type: ignore
import json
import httpx
from quart import g, jsonify, url_for, websocket
from http import HTTPStatus
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import get_copilot
from .views import updater
import shortuuid
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
webhook = None
data = None
if "copilot" != payment.extra.get("tag"):
# not an copilot invoice
return
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
copilot = await get_copilot(payment.extra.get("copilot", -1))
if not copilot:
return (
jsonify({"message": "Copilot link link does not exist."}),
HTTPStatus.NOT_FOUND,
)
if copilot.animation1threshold:
if int(payment.amount / 1000) >= copilot.animation1threshold:
data = copilot.animation1
webhook = copilot.animation1webhook
if copilot.animation2threshold:
if int(payment.amount / 1000) >= copilot.animation2threshold:
data = copilot.animation2
webhook = copilot.animation1webhook
if copilot.animation3threshold:
if int(payment.amount / 1000) >= copilot.animation3threshold:
data = copilot.animation3
webhook = copilot.animation1webhook
if webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
webhook,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
},
timeout=40,
)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
if payment.extra.get("comment"):
await updater(copilot.id, data, payment.extra.get("comment"))
else:
await updater(copilot.id, data, "none")
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View File

@ -0,0 +1,172 @@
<q-card>
<q-card-section>
<p>
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Create copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /copilot/api/v1/copilot</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/copilot -d '{"title":
&lt;string&gt;, "animation": &lt;string&gt;,
"show_message":&lt;string&gt;, "amount": &lt;integer&gt;,
"lnurl_title": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}api/v1/copilot/&lt;copilot_id&gt; -d '{"title": &lt;string&gt;,
"animation": &lt;string&gt;, "show_message":&lt;string&gt;,
"amount": &lt;integer&gt;, "lnurl_title": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key:
{{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/copilot/&lt;copilot_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilots">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /copilot/api/v1/copilots</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/copilots -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Trigger an animation"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/api/v1/copilot/ws/&lt;copilot_id&gt;/&lt;comment&gt;/&lt;data&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/api/v1/copilot/ws/&lt;string,
copilot_id&gt;/&lt;string, comment&gt;/&lt;string, gif name&gt; -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View File

@ -0,0 +1,289 @@
{% extends "public.html" %} {% block page %}<q-page>
<video
autoplay="true"
id="videoScreen"
style="width: 100%"
class="fixed-bottom-right"
></video>
<video
autoplay="true"
id="videoCamera"
style="width: 100%"
class="fixed-bottom-right"
></video>
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
<div
v-if="copilot.lnurl_toggle == 1"
class="rounded-borders column fixed-right"
style="
width: 250px;
background-color: white;
height: 300px;
margin-top: 10%;
"
>
<div class="col">
<qrcode
:value="copilot.lnurl"
:options="{width:250}"
class="rounded-borders"
></qrcode>
<center class="absolute-bottom" style="color: black; font-size: 20px">
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
</center>
</div>
</div>
<h2
v-if="copilot.show_price != 0"
class="text-bold fixed-bottom-left"
style="
margin: 60px 60px;
font-size: 110px;
text-shadow: 4px 8px 4px black;
color: white;
"
>
{% raw %}{{ price }}{% endraw %}
</h2>
<p
v-if="copilot.show_ack != 0"
class="fixed-top"
style="
font-size: 22px;
text-shadow: 2px 4px 1px black;
color: white;
padding-left: 40%;
"
>
Powered by LNbits/StreamerCopilot
</p>
</q-page>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style>
body.body--dark .q-drawer,
body.body--dark .q-footer,
body.body--dark .q-header,
.q-drawer,
.q-footer,
.q-header {
display: none;
}
.q-page {
padding: 0px;
}
</style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
price: '',
counter: 1,
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
copilot: {},
animQueue: [],
queue: false,
lnurl: ''
}
},
methods: {
showNotif: function (userMessage) {
var colour = this.colours[
Math.floor(Math.random() * this.colours.length)
]
this.$q.notify({
color: colour,
icon: 'chat_bubble_outline',
html: true,
message: '<h4 style="color: white;">' + userMessage + '</h4>',
position: 'top-left',
timeout: 5000
})
},
openURL: function (url) {
return Quasar.utils.openURL(url)
},
initCamera() {
var video = document.querySelector('#videoCamera')
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
}
},
initScreenShare() {
var video = document.querySelector('#videoScreen')
navigator.mediaDevices
.getDisplayMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
},
pushAnim(content) {
document.getElementById('animations').style.width = content[0]
document.getElementById('animations').src = content[1]
if (content[2] != 'none') {
self.showNotif(content[2])
}
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
},
launch() {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' +
self.copilot.id +
'/launching/rocket'
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
mounted() {
this.initCamera()
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + self.copilot.id,
localStorage.getItem('inkey')
)
.then(function (response) {
self.copilot = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
const obj = JSON.stringify({
event: 'bts:subscribe',
data: {channel: 'live_trades_' + self.copilot.show_price}
})
this.connectionBitStamp.onmessage = function (e) {
if (self.copilot.show_price) {
if (self.copilot.show_price == 'btcusd') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btceur') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btcgbp') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'GBP'
}).format(JSON.parse(e.data).data.price)
)
}
}
}
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
const fetch = data =>
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
const addTask = (() => {
let pending = Promise.resolve()
const run = async data => {
try {
await pending
} finally {
return fetch(data)
}
}
return data => (pending = run(data))
})()
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id +
'/'
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id +
'/'
}
this.connection = new WebSocket(localUrl)
this.connection.onmessage = function (e) {
res = e.data.split('-')
if (res[0] == 'rocket') {
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
}
if (res[0] == 'face') {
addTask(['35%', '/copilot/static/face.gif', res[1]])
}
if (res[0] == 'bitcoin') {
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
}
if (res[0] == 'confetti') {
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
}
if (res[0] == 'martijn') {
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
}
if (res[0] == 'rick') {
addTask(['40%', '/copilot/static/rick.gif', res[1]])
}
if (res[0] == 'true') {
document.getElementById('videoCamera').style.width = '20%'
self.initScreenShare()
}
if (res[0] == 'false') {
document.getElementById('videoCamera').style.width = '100%'
document.getElementById('videoScreen').src = null
}
}
this.connection.onopen = () => this.launch
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,658 @@
{% 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-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
>New copilot instance
</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">Copilots</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
<q-btn flat color="grey" @click="exportcopilotCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
flat
dense
:data="CopilotLinks"
row-key="id"
:columns="CopilotsTable.columns"
:pagination.sync="CopilotsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<!-- <q-th auto-width></q-th> -->
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="apps"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotPanel(props.row.id)"
>
<q-tooltip> Panel </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="face"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotCompose(props.row.id)"
>
<q-tooltip> Compose window </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="deleteCopilotLink(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete copilot </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="openUpdateCopilotLink(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip> Edit copilot </q-tooltip>
</q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} StreamCopilot Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCopilot.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.title"
type="text"
label="Title"
></q-input>
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.lnurl_toggle"
label="Include lnurl payment QR? (requires https)"
left-label
></q-checkbox>
</div>
<div v-if="formDialogCopilot.data.lnurl_toggle">
<q-checkbox
v-model="formDialogCopilot.data.show_message"
left-label
label="Show lnurl-pay messages? (supported by few wallets)"
></q-checkbox>
<q-select
filled
dense
emit-value
v-model="formDialogCopilot.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 1"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation1"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1threshold"
type="number"
step="1"
label="From *sats (min. 10)"
:rules="[ val => val >= 10 || 'Please use minimum 10' ]"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 2 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation1threshold > 0"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation2"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation2threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation1threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation2webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 3 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation3"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation3threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation2threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation3webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.lnurl_title"
type="text"
max="1440"
label="Lnurl title (message with QR code)"
>
</q-input>
</div>
<div class="q-gutter-sm">
<q-select
filled
dense
style="width: 50%"
v-model.trim="formDialogCopilot.data.show_price"
:options="currencyOptions"
label="Show price"
/>
</div>
<div class="q-gutter-sm">
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.show_ack"
left-label
label="Show 'powered by LNbits'"
></q-checkbox>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogCopilot.data.id"
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Update Copilot</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Create Copilot</q-btn
>
<q-btn @click="cancelCopilot" 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 src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style></style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
var mapCopilot = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
CopilotLinks: [],
CopilotLinksObj: [],
CopilotsTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'id',
field: 'id'
},
{
name: 'lnurl_toggle',
align: 'left',
label: 'Show lnurl pay link',
field: 'lnurl_toggle'
},
{
name: 'title',
align: 'left',
label: 'title',
field: 'title'
},
{
name: 'amount_made',
align: 'left',
label: 'amount made',
field: 'amount_made'
}
],
pagination: {
rowsPerPage: 10
}
},
passedCopilot: {},
formDialog: {
show: false,
data: {}
},
formDialogCopilot: {
show: false,
data: {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
qrCodeDialog: {
show: false,
data: null
},
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
}
},
methods: {
cancelCopilot: function (data) {
var self = this
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
},
closeFormDialog: function () {
this.clearFormDialogCopilot()
this.formDialog.data = {
is_unique: false
}
},
sendFormDataCopilot: function () {
var self = this
if (self.formDialogCopilot.data.id) {
this.updateCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
} else {
this.createCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
}
},
createCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
.then(function (response) {
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilots: function () {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot',
this.g.user.wallets[0].inkey
)
.then(function (response) {
if(response.data){
self.CopilotLinks = response.data.map(mapCopilot)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilot: function (copilot_id) {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + copilot_id,
this.g.user.wallets[0].inkey
)
.then(function (response) {
localStorage.setItem('copilot', JSON.stringify(response.data))
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
openCopilotCompose: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../copilot/cp/', '_blank', params)
},
openCopilotPanel: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
open('../copilot/pn/', '_blank', params)
},
deleteCopilotLink: function (copilotId) {
var self = this
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/copilot/api/v1/copilot/' + copilotId,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === copilotId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
openUpdateCopilotLink: function (copilotId) {
var self = this
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
self.formDialogCopilot.data = _.clone(copilot._data)
self.formDialogCopilot.show = true
},
updateCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request(
'PUT',
'/copilot/api/v1/copilot/' + updatedData.id,
wallet,
updatedData
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === updatedData.id
})
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
clearFormDialogCopilot(){
this.formDialogCopilot.data = {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
exportcopilotCSV: function () {
var self = this
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
}
},
created: function () {
var self = this
var getCopilots = this.getCopilots
getCopilots()
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,157 @@
{% extends "public.html" %} {% block page %}
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
<q-card class="my-card">
<div class="column">
<div class="col">
<center>
<q-btn
flat
round
dense
@click="openCompose"
icon="face"
style="font-size: 60px"
></q-btn>
</center>
</div>
<center>
<div class="col" style="margin: 15px; font-size: 22px">
Title: {% raw %} {{ copilot.title }} {% endraw %}
</div>
</center>
<q-separator></q-separator>
<div class="col">
<div class="row">
<div class="col">
<q-btn
class="q-mt-sm q-ml-sm"
color="primary"
@click="fullscreenToggle"
label="Screen share"
size="sm"
>
</q-btn>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rocket')"
label="rocket"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('confetti')"
label="confetti"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('face')"
label="face"
size="sm"
/>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rick')"
label="rick"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('martijn')"
label="martijn"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('bitcoin')"
label="bitcoin"
size="sm"
/>
</div>
</div>
</div>
</div>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fullscreen_cam: true,
textareaModel: '',
iframe: '',
copilot: {}
}
},
methods: {
iframeChange: function (url) {
this.connection.send(String(url))
},
fullscreenToggle: function () {
self = this
self.animationBTN(String(this.fullscreen_cam))
if (this.fullscreen_cam) {
this.fullscreen_cam = false
} else {
this.fullscreen_cam = true
}
},
openCompose: function () {
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../cp/', 'test', params)
},
animationBTN: function (name) {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,61 @@
from quart import g, abort, render_template, jsonify, websocket
from http import HTTPStatus
import httpx
from collections import defaultdict
from lnbits.decorators import check_user_exists, validate_uuids
from . import copilot_ext
from .crud import get_copilot
from quart import g, abort, render_template, jsonify, websocket
from functools import wraps
import trio
import shortuuid
from . import copilot_ext
@copilot_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("copilot/index.html", user=g.user)
@copilot_ext.route("/cp/")
async def compose():
return await render_template("copilot/compose.html")
@copilot_ext.route("/pn/")
async def panel():
return await render_template("copilot/panel.html")
##################WEBSOCKET ROUTES########################
# socket_relay is a list where the control panel or
# lnurl endpoints can leave a message for the compose window
connected_websockets = defaultdict(set)
@copilot_ext.websocket("/ws/<id>/")
async def wss(id):
copilot = await get_copilot(id)
if not copilot:
return "", HTTPStatus.FORBIDDEN
global connected_websockets
send_channel, receive_channel = trio.open_memory_channel(0)
connected_websockets[id].add(send_channel)
try:
while True:
data = await receive_channel.receive()
await websocket.send(data)
finally:
connected_websockets[id].remove(send_channel)
async def updater(copilot_id, data, comment):
copilot = await get_copilot(copilot_id)
if not copilot:
return
for queue in connected_websockets[copilot_id]:
await queue.send(f"{data + '-' + comment}")

View File

@ -0,0 +1,109 @@
import hashlib
from quart import g, jsonify, url_for, websocket
from http import HTTPStatus
import httpx
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from .views import updater
from . import copilot_ext
from lnbits.extensions.copilot import copilot_ext
from .crud import (
create_copilot,
update_copilot,
get_copilot,
get_copilots,
delete_copilot,
)
#######################COPILOT##########################
@copilot_ext.route("/api/v1/copilot", methods=["POST"])
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"title": {"type": "string", "empty": False, "required": True},
"lnurl_toggle": {"type": "integer", "empty": False},
"wallet": {"type": "string", "empty": False, "required": False},
"animation1": {"type": "string", "empty": True, "required": False},
"animation2": {"type": "string", "empty": True, "required": False},
"animation3": {"type": "string", "empty": True, "required": False},
"animation1threshold": {"type": "integer", "empty": True, "required": False},
"animation2threshold": {"type": "integer", "empty": True, "required": False},
"animation3threshold": {"type": "integer", "empty": True, "required": False},
"animation1webhook": {"type": "string", "empty": True, "required": False},
"animation2webhook": {"type": "string", "empty": True, "required": False},
"animation3webhook": {"type": "string", "empty": True, "required": False},
"lnurl_title": {"type": "string", "empty": True, "required": False},
"show_message": {"type": "integer", "empty": True, "required": False},
"show_ack": {"type": "integer", "empty": True},
"show_price": {"type": "string", "empty": True},
}
)
async def api_copilot_create_or_update(copilot_id=None):
if not copilot_id:
copilot = await create_copilot(user=g.wallet.user, **g.data)
return jsonify(copilot._asdict()), HTTPStatus.CREATED
else:
copilot = await update_copilot(copilot_id=copilot_id, **g.data)
return jsonify(copilot._asdict()), HTTPStatus.OK
@copilot_ext.route("/api/v1/copilot", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_copilots_retrieve():
try:
return (
jsonify(
[{**copilot._asdict()} for copilot in await get_copilots(g.wallet.user)]
),
HTTPStatus.OK,
)
except:
return ""
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_copilot_retrieve(copilot_id):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND
if not copilot.lnurl_toggle:
return (
jsonify({**copilot._asdict()}),
HTTPStatus.OK,
)
return (
jsonify({**copilot._asdict(), **{"lnurl": copilot.lnurl}}),
HTTPStatus.OK,
)
@copilot_ext.route("/api/v1/copilot/<copilot_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_copilot_delete(copilot_id):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
await delete_copilot(copilot_id)
return "", HTTPStatus.NO_CONTENT
@copilot_ext.route("/api/v1/copilot/ws/<copilot_id>/<comment>/<data>", methods=["GET"])
async def api_copilot_ws_relay(copilot_id, comment, data):
copilot = await get_copilot(copilot_id)
if not copilot:
return jsonify({"message": "copilot does not exist"}), HTTPStatus.NOT_FOUND
try:
await updater(copilot_id, data, comment)
except:
return "", HTTPStatus.FORBIDDEN
return "", HTTPStatus.OK