diff --git a/lnbits/extensions/copilot/README.md b/lnbits/extensions/copilot/README.md
new file mode 100644
index 00000000..323aeddc
--- /dev/null
+++ b/lnbits/extensions/copilot/README.md
@@ -0,0 +1,3 @@
+# StreamerCopilot
+
+Tool to help streamers accept sats for tips
diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py
new file mode 100644
index 00000000..94d1e74c
--- /dev/null
+++ b/lnbits/extensions/copilot/__init__.py
@@ -0,0 +1,37 @@
+import asyncio
+from fastapi import APIRouter, FastAPI
+from fastapi.staticfiles import StaticFiles
+from starlette.routing import Mount
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_copilot")
+
+copilot_static_files = [
+ {
+ "path": "/copilot/static",
+ "app": StaticFiles(directory="lnbits/extensions/copilot/static"),
+ "name": "copilot_static",
+ }
+]
+copilot_ext: APIRouter = APIRouter(prefix="/copilot", tags=["copilot"])
+
+
+def copilot_renderer():
+ return template_renderer(
+ [
+ "lnbits/extensions/copilot/templates",
+ ]
+ )
+
+
+from .views_api import * # noqa
+from .views import * # noqa
+from .tasks import wait_for_paid_invoices
+from .lnurl import * # noqa
+
+
+def copilot_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/copilot/config.json b/lnbits/extensions/copilot/config.json
new file mode 100644
index 00000000..a4ecb3b5
--- /dev/null
+++ b/lnbits/extensions/copilot/config.json
@@ -0,0 +1,8 @@
+{
+ "name": "Streamer Copilot",
+ "short_description": "Video tips/animations/webhooks",
+ "icon": "face",
+ "contributors": [
+ "arcbtc"
+ ]
+}
diff --git a/lnbits/extensions/copilot/crud.py b/lnbits/extensions/copilot/crud.py
new file mode 100644
index 00000000..ce4a2804
--- /dev/null
+++ b/lnbits/extensions/copilot/crud.py
@@ -0,0 +1,91 @@
+from typing import List, Optional, Union
+
+from . import db
+from .models import Copilots, CreateCopilotData
+from lnbits.helpers import urlsafe_short_hash
+
+###############COPILOTS##########################
+
+
+async def create_copilot(
+ data: CreateCopilotData, inkey: Optional[str] = ""
+) -> 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ data.copilot_id,
+ data.user,
+ int(data.lnurl_toggle),
+ data.wallet,
+ data.title,
+ data.animation1,
+ data.animation2,
+ data.animation3,
+ data.animation1threshold,
+ data.animation2threshold,
+ data.animation3threshold,
+ data.animation1webhook,
+ data.animation2webhook,
+ data.animation3webhook,
+ data.lnurl_title,
+ int(data.show_message),
+ int(data.show_ack),
+ data.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,))
diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py
new file mode 100644
index 00000000..518b07f3
--- /dev/null
+++ b/lnbits/extensions/copilot/lnurl.py
@@ -0,0 +1,92 @@
+import json
+import hashlib
+import math
+from fastapi import Request
+import hashlib
+from http import HTTPStatus
+
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse, JSONResponse # type: ignore
+import base64
+from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
+from lnurl.types import LnurlPayMetadata
+from lnbits.core.services import create_invoice
+from .models import Copilots, CreateCopilotData
+from . import copilot_ext
+from .crud import get_copilot
+from typing import Optional
+from fastapi.params import Depends
+from fastapi.param_functions import Query
+
+
+@copilot_ext.get("/lnurl/{cp_id}", response_class=HTMLResponse)
+async def lnurl_response(req: Request, cp_id: str = Query(None)):
+ cp = await get_copilot(cp_id)
+ if not cp:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Copilot not found",
+ )
+
+ resp = LnurlPayResponse(
+ callback=req.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 params
+
+
+@copilot_ext.get("/lnurl/cb/{cp_id}", response_class=HTMLResponse)
+async def lnurl_callback(
+ cp_id: str = Query(None), amount: str = Query(None), comment: str = Query(None)
+):
+ cp = await get_copilot(cp_id)
+ if not cp:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Copilot not found",
+ )
+
+ amount_received = int(amount)
+
+ if amount_received < 10000:
+ return LnurlErrorResponse(
+ reason=f"Amount {round(amount_received / 1000)} is smaller than minimum 10 sats."
+ ).dict()
+ elif amount_received / 1000 > 10000000:
+ return LnurlErrorResponse(
+ reason=f"Amount {round(amount_received / 1000)} is greater than maximum 50000."
+ ).dict()
+ comment = ""
+ if comment:
+ if len(comment or "") > 300:
+ return 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 resp.dict()
diff --git a/lnbits/extensions/copilot/migrations.py b/lnbits/extensions/copilot/migrations.py
new file mode 100644
index 00000000..c1fbfc0d
--- /dev/null
+++ b/lnbits/extensions/copilot/migrations.py
@@ -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;")
diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py
new file mode 100644
index 00000000..230825a0
--- /dev/null
+++ b/lnbits/extensions/copilot/models.py
@@ -0,0 +1,68 @@
+import json
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
+from starlette.requests import Request
+from fastapi.param_functions import Query
+from typing import Optional, Dict
+from lnbits.lnurl import encode as lnurl_encode # type: ignore
+from lnurl.types import LnurlPayMetadata # type: ignore
+from sqlite3 import Row
+from pydantic import BaseModel
+
+
+class CreateCopilotData(BaseModel):
+ id: str = Query(None)
+ user: str = Query(None)
+ title: str = Query(None)
+ lnurl_toggle: int = Query(None)
+ wallet: str = Query(None)
+ animation1: str = Query(None)
+ animation2: str = Query(None)
+ animation3: str = Query(None)
+ animation1threshold: int = Query(None)
+ animation2threshold: int = Query(None)
+ animation3threshold: int = Query(None)
+ animation1webhook: str = Query(None)
+ animation2webhook: str = Query(None)
+ animation3webhook: str = Query(None)
+ lnurl_title: str = Query(None)
+ show_message: int = Query(None)
+ show_ack: int = Query(None)
+ show_price: int = Query(None)
+ amount_made: int = Query(None)
+ timestamp: int = Query(None)
+ fullscreen_cam: int = Query(None)
+ iframe_url: str = Query(None)
+ success_url: str = Query(None)
+
+
+class Copilots(BaseModel):
+ 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))
+
+ def lnurl(self, req: Request) -> str:
+ url = req.url_for("copilot.lnurl_response", link_id=self.id)
+ return lnurl_encode(url)
diff --git a/lnbits/extensions/copilot/static/bitcoin.gif b/lnbits/extensions/copilot/static/bitcoin.gif
new file mode 100644
index 00000000..ef8c2ecd
Binary files /dev/null and b/lnbits/extensions/copilot/static/bitcoin.gif differ
diff --git a/lnbits/extensions/copilot/static/confetti.gif b/lnbits/extensions/copilot/static/confetti.gif
new file mode 100644
index 00000000..a3fec971
Binary files /dev/null and b/lnbits/extensions/copilot/static/confetti.gif differ
diff --git a/lnbits/extensions/copilot/static/face.gif b/lnbits/extensions/copilot/static/face.gif
new file mode 100644
index 00000000..3e70d779
Binary files /dev/null and b/lnbits/extensions/copilot/static/face.gif differ
diff --git a/lnbits/extensions/copilot/static/lnurl.png b/lnbits/extensions/copilot/static/lnurl.png
new file mode 100644
index 00000000..ad2c9715
Binary files /dev/null and b/lnbits/extensions/copilot/static/lnurl.png differ
diff --git a/lnbits/extensions/copilot/static/martijn.gif b/lnbits/extensions/copilot/static/martijn.gif
new file mode 100644
index 00000000..e410677d
Binary files /dev/null and b/lnbits/extensions/copilot/static/martijn.gif differ
diff --git a/lnbits/extensions/copilot/static/rick.gif b/lnbits/extensions/copilot/static/rick.gif
new file mode 100644
index 00000000..c36c7e19
Binary files /dev/null and b/lnbits/extensions/copilot/static/rick.gif differ
diff --git a/lnbits/extensions/copilot/static/rocket.gif b/lnbits/extensions/copilot/static/rocket.gif
new file mode 100644
index 00000000..6f19597d
Binary files /dev/null and b/lnbits/extensions/copilot/static/rocket.gif differ
diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py
new file mode 100644
index 00000000..ea678222
--- /dev/null
+++ b/lnbits/extensions/copilot/tasks.py
@@ -0,0 +1,87 @@
+import asyncio
+import json
+import httpx
+
+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
+from http import HTTPStatus
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse, JSONResponse # type: ignore
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ 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:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Copilot does not exist",
+ )
+ 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),
+ )
diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html
new file mode 100644
index 00000000..d6289be9
--- /dev/null
+++ b/lnbits/extensions/copilot/templates/copilot/_api_docs.html
@@ -0,0 +1,172 @@
+
+ StreamerCopilot: get tips via static QR (lnurl-pay) and show an
+ animation
+ Powered by LNbits/StreamerCopilot
+
+
+ Created by, Ben Arc
+ POST /copilot/api/v1/copilot
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X POST {{ request.url_root }}api/v1/copilot -d '{"title":
+ <string>, "animation": <string>,
+ "show_message":<string>, "amount": <integer>,
+ "lnurl_title": <string>}' -H "Content-type: application/json"
+ -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
+
+ PUT
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X POST {{ request.url_root
+ }}api/v1/copilot/<copilot_id> -d '{"title": <string>,
+ "animation": <string>, "show_message":<string>,
+ "amount": <integer>, "lnurl_title": <string>}' -H
+ "Content-type: application/json" -H "X-Api-Key:
+ {{g.user.wallets[0].adminkey }}"
+
+ GET
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/copilot/<copilot_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+
+ GET /copilot/api/v1/copilots
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/copilots -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
+
+ DELETE
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.url_root
+ }}api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
+
+ GET
+ /api/v1/copilot/ws/<copilot_id>/<comment>/<data>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 200
+
+ Curl example
+ curl -X GET {{ request.url_root }}/api/v1/copilot/ws/<string,
+ copilot_id>/<string, comment>/<string, gif name> -H
+ "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+
+
+
+
+ {% raw %}{{ price }}{% endraw %}
+
+