Removed streamer copilot, as we need a demo

This commit is contained in:
ben 2023-01-25 20:58:28 +00:00
parent ad70e9c116
commit 925d4ccbe7
23 changed files with 4 additions and 1881 deletions

View File

@ -62,8 +62,8 @@
<div class="row">
<div class="col-3">
<q-img
v-if="extension.iconUrl"
src="extension.iconUrl"
v-if="extension.icon"
src="extension.icon"
spinner-color="white"
style="max-width: 100%"
></q-img>
@ -114,7 +114,9 @@
style="font-size: 9px; height: 34px"
>
{{ extension.shortDescription }}
</div>
{{ extension }}
{% endraw %}
</div>
</div>

View File

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

View File

@ -1,34 +0,0 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
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(packages=[("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 .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def copilot_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -1,8 +0,0 @@
{
"name": "Streamer Copilot",
"short_description": "Video tips/animations/webhooks",
"tile": "/copilot/static/bitcoin-streaming.png",
"contributors": [
"arcbtc"
]
}

View File

@ -1,97 +0,0 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Copilots, CreateCopilotData
###############COPILOTS##########################
async def create_copilot(
data: CreateCopilotData, inkey: Optional[str] = ""
) -> Optional[Copilots]:
copilot_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO copilot.newer_copilots (
id,
"user",
lnurl_toggle,
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
show_message,
show_ack,
show_price,
fullscreen_cam,
iframe_url,
amount_made
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
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,
None,
0,
),
)
return await get_copilot(copilot_id)
async def update_copilot(
data: CreateCopilotData, copilot_id: str
) -> Optional[Copilots]:
q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data]
items.append(copilot_id)
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items,))
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)
return Copilots(**row) if row else None
async def get_copilot(copilot_id: str) -> Optional[Copilots]:
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)
return Copilots(**row) if row else None
async def get_copilots(user: str) -> List[Copilots]:
rows = await db.fetchall(
'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
)
return [Copilots(**row) for row in rows]
async def delete_copilot(copilot_id: str) -> None:
await db.execute("DELETE FROM copilot.newer_copilots WHERE id = ?", (copilot_id,))

View File

@ -1,82 +0,0 @@
import hashlib
import json
from http import HTTPStatus
from fastapi import Request
from fastapi.param_functions import Query
from lnurl.types import LnurlPayMetadata
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.services import create_invoice
from . import copilot_ext
from .crud import get_copilot
@copilot_ext.get(
"/lnurl/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_response"
)
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"
)
payResponse = {
"tag": "payRequest",
"callback": req.url_for("copilot.lnurl_callback", cp_id=cp_id),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
"maxSendable": 50000000,
"minSendable": 10000,
}
if cp.show_message:
payResponse["commentAllowed"] = 300
return json.dumps(payResponse)
@copilot_ext.get(
"/lnurl/cb/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_callback"
)
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:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Amount {round(amount_received / 1000)} is smaller than minimum 10 sats.",
)
elif amount_received / 1000 > 10000000:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Amount {round(amount_received / 1000)} is greater than maximum 50000.",
)
comment = ""
if comment:
if len(comment or "") > 300:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Got a comment with {len(comment)} characters, but can only accept 300",
)
if len(comment) < 1:
comment = "none"
_, payment_request = await create_invoice(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
unhashed_description=(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode(),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
)
payResponse = {"pr": payment_request, "routes": []}
return json.dumps(payResponse)

View File

@ -1,79 +0,0 @@
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;"
)
async def m003_fix_data_types(db):
await db.execute(
f"""
CREATE TABLE copilot.newer_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.newer_copilots SELECT * FROM copilot.copilots"
)

View File

@ -1,66 +0,0 @@
import json
from sqlite3 import Row
from typing import Dict, Optional
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from fastapi.param_functions import Query
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
from starlette.requests import Request
from lnbits.lnurl import encode as lnurl_encode
class CreateCopilotData(BaseModel):
user: str = Query(None)
title: str = Query(None)
lnurl_toggle: int = Query(0)
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(0)
show_ack: int = Query(0)
show_price: str = Query(None)
amount_made: int = Query(0)
timestamp: int = Query(0)
fullscreen_cam: int = Query(0)
iframe_url: str = Query(None)
success_url: str = Query(None)
class Copilots(BaseModel):
id: str
user: str = Query(None)
title: str = Query(None)
lnurl_toggle: int = Query(0)
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(0)
show_ack: int = Query(0)
show_price: str = Query(None)
amount_made: int = Query(0)
timestamp: int = Query(0)
fullscreen_cam: int = Query(0)
iframe_url: str = Query(None)
success_url: str = Query(None)
def lnurl(self, req: Request) -> str:
url = req.url_for("copilot.lnurl_response", cp_id=self.id)
return lnurl_encode(url)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

View File

@ -1,84 +0,0 @@
import asyncio
import json
from http import HTTPStatus
import httpx
from starlette.exceptions import HTTPException
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.core.services import websocketUpdater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_copilot
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "copilot":
# not an copilot invoice
return
webhook = None
data = None
copilot = await get_copilot(payment.extra.get("copilotid", -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 websocketUpdater(
copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
)
await websocketUpdater(copilot.id, str(data) + "-none")
async def mark_webhook_sent(payment: Payment, status: int) -> None:
if payment.extra:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View File

@ -1,178 +0,0 @@
<q-card>
<q-card-section>
<p>
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by,
<a class="text-secondary" 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-btn flat label="Swagger API" type="a" href="../docs#/copilot"></q-btn>
<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.base_url }}copilot/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: {{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.base_url
}}copilot/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: {{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.base_url
}}copilot/api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
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.base_url }}copilot/api/v1/copilots -H
"X-Api-Key: {{ 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.base_url
}}copilot/api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
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.base_url
}}copilot/api/v1/copilot/ws/&lt;string, copilot_id&gt;/&lt;string,
comment&gt;/&lt;string, gif name&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View File

@ -1,305 +0,0 @@
{% 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"
id="draggableqr"
class="rounded-borders"
style="
width: 250px;
background-color: white;
height: 300px;
margin-top: 10%;
"
>
<div class="col">
<qrcode
:value="'lightning:' + 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
id="draggableprice"
v-if="copilot.show_price != 0"
class="text-bold"
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 %}
<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;
}
#draggableqr {
width: 250px;
height: 300px;
cursor: grab;
}
#draggableprice {
width: 550px;
height: 60px;
cursor: grab;
}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.js"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<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 () {
$(function () {
$('#draggableqr').draggable()
$('#draggableprice').draggable()
}),
(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 +
'/api/v1/ws/' +
self.copilot.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
self.copilot.id
}
this.connection = new WebSocket(localUrl)
this.connection.onmessage = function (e) {
console.log(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

@ -1,660 +0,0 @@
{% 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>
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 {
console.log(self.g.user.wallets[0].adminkey)
console.log(self.formDialogCopilot.data)
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

@ -1,156 +0,0 @@
{% 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>
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

@ -1,33 +0,0 @@
from typing import List
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import copilot_ext, copilot_renderer
templates = Jinja2Templates(directory="templates")
@copilot_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return copilot_renderer().TemplateResponse(
"copilot/index.html", {"request": request, "user": user.dict()}
)
@copilot_ext.get("/cp/", response_class=HTMLResponse)
async def compose(request: Request):
return copilot_renderer().TemplateResponse(
"copilot/compose.html", {"request": request}
)
@copilot_ext.get("/pn/", response_class=HTMLResponse)
async def panel(request: Request):
return copilot_renderer().TemplateResponse(
"copilot/panel.html", {"request": request}
)

View File

@ -1,94 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, Query, Request
from starlette.exceptions import HTTPException
from lnbits.core.services import websocketUpdater
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import copilot_ext
from .crud import (
create_copilot,
delete_copilot,
get_copilot,
get_copilots,
update_copilot,
)
from .models import CreateCopilotData
#######################COPILOT##########################
@copilot_ext.get("/api/v1/copilot")
async def api_copilots_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
wallet_user = wallet.wallet.user
copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
try:
return copilots
except:
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No copilots")
@copilot_ext.get("/api/v1/copilot/{copilot_id}")
async def api_copilot_retrieve(
req: Request,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(get_key_type),
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
)
if not copilot.lnurl_toggle:
return copilot.dict()
return {**copilot.dict(), **{"lnurl": copilot.lnurl(req)}}
@copilot_ext.post("/api/v1/copilot")
@copilot_ext.put("/api/v1/copilot/{juke_id}")
async def api_copilot_create_or_update(
data: CreateCopilotData,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
data.user = wallet.wallet.user
data.wallet = wallet.wallet.id
if copilot_id:
copilot = await update_copilot(data, copilot_id=copilot_id)
else:
copilot = await create_copilot(data, inkey=wallet.wallet.inkey)
return copilot
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
async def api_copilot_delete(
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
await delete_copilot(copilot_id)
return "", HTTPStatus.NO_CONTENT
@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
async def api_copilot_ws_relay(
copilot_id: str = Query(None), comment: str = Query(None), data: str = Query(None)
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
try:
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
return ""