[FEAT] Push notification integration into core (#1393)

* push notification integration into core

added missing component

fixed bell working on all pages
- made pubkey global template env var
- had to move `get_push_notification_pubkey` to `helpers.py` because of circular reference with `tasks.py`

formay

trying to fix mypy

added py-vapid to requirements

Trying to fix stub mypy issue

* removed key files

* webpush key pair is saved in db `webpush_settings`

* removed lnaddress extension changes

* support for multi user account subscriptions, subscriptions are stored user based

fixed syntax error

fixed syntax error

removed unused line

* fixed subscribed user storage with local storage, no get request required

* method is singular now

* cleanup unsubscribed or expired push subscriptions

fixed flake8 errors

fixed poetry errors

* updating to latest lnbits

formatting, rebase error

fix

* remove unused?

* revert

* relock

* remove

* do not create settings table use adminsettings

mypy

fix

* cleanup old code

* catch case when client tries to recreate existing webpush subscription e.g. on cleared local storage

* show notification bell on user related pages only

* use local storage with one key like array, some refactoring

* fixed crud import

* fixed too long line

* removed unused imports

* ruff

* make webpush editable

* fixed privkey encoding

* fix ruff

* fix migration

---------

Co-authored-by: schneimi <admin@schneimi.de>
Co-authored-by: schneimi <dev@schneimi.de>
Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
schneimi 2023-09-11 15:48:49 +02:00 committed by GitHub
parent 8f0c1f6a80
commit fb98576431
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 571 additions and 21 deletions

View file

@ -39,7 +39,7 @@ from .core import (
core_app_extra,
update_installed_extension_state,
)
from .core.services import check_admin_settings
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.generic import core_html_routes
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
from .helpers import template_renderer
@ -332,6 +332,7 @@ def register_startup(app: FastAPI):
# setup admin settings
await check_admin_settings()
await check_webpush_settings()
log_server_info()

View file

@ -10,10 +10,23 @@ from lnbits import bolt11
from lnbits.core.models import WalletType
from lnbits.db import Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
from lnbits.settings import (
AdminSettings,
SuperSettings,
WebPushSettings,
settings,
)
from . import db
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
from .models import (
BalanceCheck,
Payment,
PaymentFilters,
TinyURL,
User,
Wallet,
WebPushSubscription,
)
# accounts
# --------
@ -788,8 +801,16 @@ async def delete_admin_settings():
await db.execute("DELETE FROM settings")
async def update_admin_settings(data: EditableSettings):
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
async def update_admin_settings(data: dict):
row = await db.fetchone("SELECT editable_settings FROM settings")
if not row:
return None
editable_settings = json.loads(row["editable_settings"])
for key, value in data.items():
editable_settings[key] = value
await db.execute(
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
)
async def update_super_user(super_user: str) -> SuperSettings:
@ -872,3 +893,82 @@ async def delete_tinyurl(tinyurl_id: str):
"DELETE FROM tiny_url WHERE id = ?",
(tinyurl_id,),
)
# push_notification
# -----------------
async def get_webpush_settings() -> Optional[WebPushSettings]:
row = await db.fetchone("SELECT * FROM webpush_settings")
if not row:
return None
vapid_keypair = json.loads(row["vapid_keypair"])
return WebPushSettings(**vapid_keypair)
async def create_webpush_settings(webpush_settings: dict):
await db.execute(
"INSERT INTO webpush_settings (vapid_keypair) VALUES (?)",
(json.dumps(webpush_settings),),
)
return await get_webpush_settings()
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
row = await db.fetchone(
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
(
endpoint,
user,
),
)
return WebPushSubscription(**dict(row)) if row else None
async def get_webpush_subscriptions_for_user(
user: str,
) -> List[WebPushSubscription]:
rows = await db.fetchall(
"SELECT * FROM webpush_subscriptions WHERE user = ?",
(user,),
)
return [WebPushSubscription(**dict(row)) for row in rows]
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
VALUES (?, ?, ?, ?)
""",
(
endpoint,
user,
data,
host,
),
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
(
endpoint,
user,
),
)
async def delete_webpush_subscriptions(endpoint: str) -> None:
await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
)

View file

@ -378,3 +378,18 @@ async def m014_set_deleted_wallets(db):
# catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice.
pass
async def m015_create_push_notification_subscriptions_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
endpoint TEXT NOT NULL,
"user" TEXT NOT NULL,
data TEXT NOT NULL,
host TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
PRIMARY KEY (endpoint, "user")
);
"""
)

View file

@ -330,3 +330,15 @@ class CreateTopup(BaseModel):
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str

View file

@ -6,10 +6,13 @@ from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
import httpx
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits import bolt11
from lnbits.db import Connection
@ -41,6 +44,7 @@ from .crud import (
get_total_balance,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
@ -524,7 +528,7 @@ async def check_admin_settings():
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from enviroment variables.")
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
@ -550,6 +554,29 @@ async def check_admin_settings():
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(push_settings)
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key not in readonly_variables:

View file

@ -56,3 +56,32 @@ self.addEventListener('fetch', event => {
)
}
})
// Handle and show incoming push notifications
self.addEventListener('push', function (event) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
return
}
let data = event.data.json()
const title = data.title
const body = data.body
const url = data.url
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: '/favicon.ico',
data: {
url: url
}
})
)
})
// User can click on the notification message to open wallet
// Installed app will open when `url_handlers` in web app manifest is supported
self.addEventListener('notificationclick', function (event) {
event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data.url))
})

View file

@ -10,10 +10,15 @@ from lnbits.tasks import (
create_permanent_task,
create_task,
register_invoice_listener,
send_push_notification,
)
from . import db
from .crud import get_balance_notify, get_wallet
from .crud import (
get_balance_notify,
get_wallet,
get_webpush_subscriptions_for_user,
)
from .models import Payment
from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet
@ -119,6 +124,8 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
except (httpx.ConnectError, httpx.RequestError):
pass
await send_payment_push_notification(payment)
async def dispatch_api_invoice_listeners(payment: Payment):
"""
@ -159,3 +166,24 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None:
""",
(status, payment.payment_hash),
)
async def send_payment_push_notification(payment: Payment):
wallet = await get_wallet(payment.wallet_id)
if wallet:
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)

View file

@ -19,7 +19,7 @@ from lnbits.core.services import (
)
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, EditableSettings, settings
from lnbits.settings import AdminSettings, settings
from .. import core_app, core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
@ -58,9 +58,7 @@ async def api_get_settings(
"/admin/api/v1/settings/",
status_code=HTTPStatus.OK,
)
async def api_update_settings(
data: EditableSettings, user: User = Depends(check_admin)
):
async def api_update_settings(data: dict, user: User = Depends(check_admin)):
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found."

View file

@ -1,11 +1,12 @@
import asyncio
import base64
import hashlib
import json
import uuid
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
@ -33,12 +34,14 @@ from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
CreateLnurlAuth,
CreateWebPushSubscription,
DecodePayment,
Payment,
PaymentFilters,
User,
Wallet,
WalletType,
WebPushSubscription,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
@ -69,9 +72,11 @@ from .. import core_app, core_app_extra, db
from ..crud import (
add_installed_extension,
create_tinyurl,
create_webpush_subscription,
delete_dbversion,
delete_installed_extension,
delete_tinyurl,
delete_webpush_subscription,
drop_extension_db,
get_dbversions,
get_payments,
@ -80,6 +85,7 @@ from ..crud import (
get_tinyurl,
get_tinyurl_by_url,
get_wallet_for_key,
get_webpush_subscription,
save_balance_check,
update_wallet,
)
@ -986,3 +992,39 @@ async def api_tinyurl(tinyurl_id: str):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
)
############################WEBPUSH##################################
@core_app.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
async def api_create_webpush_subscription(
request: Request,
data: CreateWebPushSubscription,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> WebPushSubscription:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
wallet.wallet.user,
data.subscription,
host,
)
@core_app.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription(
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
endpoint = unquote(
base64.b64decode(request.query_params.get("endpoint")).decode("utf-8")
)
await delete_webpush_subscription(endpoint, wallet.wallet.user)

View file

@ -1,6 +1,7 @@
import asyncio
from http import HTTPStatus
from typing import List, Optional
from urllib.parse import urlparse
from fastapi import Depends, Query, Request, status
from fastapi.exceptions import HTTPException
@ -359,7 +360,9 @@ async def service_worker():
@core_html_routes.get("/manifest/{usr}.webmanifest")
async def manifest(usr: str):
async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc
user = await get_user(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
@ -393,6 +396,7 @@ async def manifest(usr: str):
}
for wallet in user.wallets
],
"url_handlers": [{"origin": f"https://{host}"}],
}

View file

@ -65,6 +65,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
return t

View file

@ -228,6 +228,11 @@ class FundingSourcesSettings(
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str = Field(default=None)
lnbits_webpush_privkey: str = Field(default=None)
class EditableSettings(
UsersSettings,
ExtensionsSettings,
@ -237,6 +242,7 @@ class EditableSettings(
FundingSourcesSettings,
BoltzExtensionSettings,
LightningSettings,
WebPushSettings,
):
@validator(
"lnbits_admin_users",

View file

@ -215,38 +215,38 @@ Vue.component('lnbits-payment-details', {
},
template: `
<div class="q-py-md" style="text-align: left">
<div v-if="payment.tag" class="row justify-center q-mb-md">
<q-badge v-if="hasTag" color="yellow" text-color="black">
#{{ payment.tag }}
</q-badge>
</div>
<div class="row">
<b v-text="$t('created')"></b>:
{{ payment.date }} ({{ payment.dateFrom }})
</div>
<div class="row">
<b v-text="$t('expiry')"></b>:
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
</div>
<div class="row">
<b v-text="$t('amount')"></b>:
{{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="row">
<b v-text="$t('fee')"></b>:
{{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('payment_hash')"></b>:&nbsp;{{ payment.payment_hash }}
<q-icon name="content_copy" @click="copyText(payment.payment_hash)" size="1em" color="grey" class="q-mb-xs cursor-pointer" />
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('memo')"></b>:&nbsp;{{ payment.memo }}
</div>
@ -346,3 +346,216 @@ Vue.component('lnbits-lnurlpay-success-action', {
)
}
})
Vue.component('lnbits-notifications-btn', {
mixins: [windowMixin],
props: ['pubkey'],
data() {
return {
isSupported: false,
isSubscribed: false,
isPermissionGranted: false,
isPermissionDenied: false
}
},
template: `
<q-btn
v-if="g.user.wallets"
:disabled="!this.isSupported"
dense
flat
round
@click="toggleNotifications()"
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
size="sm"
type="a"
>
<q-tooltip v-if="this.isSupported && !this.isSubscribed">Subscribe to notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isSubscribed">Unsubscribe from notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
Notifications are disabled,<br/>please enable or reset permissions
</q-tooltip>
<q-tooltip v-if="!this.isSupported">Notifications are not supported</q-tooltip>
</q-btn>
`,
methods: {
// converts base64 to Array buffer
urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
},
toggleNotifications() {
this.isSubscribed ? this.unsubscribe() : this.subscribe()
},
saveUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
if (!subscribedUsers.includes(user)) subscribedUsers.push(user)
this.$q.localStorage.set(
'lnbits.webpush.subscribedUsers',
JSON.stringify(subscribedUsers)
)
},
removeUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
subscribedUsers = subscribedUsers.filter(arr => arr !== user)
this.$q.localStorage.set(
'lnbits.webpush.subscribedUsers',
JSON.stringify(subscribedUsers)
)
},
isUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
return subscribedUsers.includes(user)
},
subscribe() {
var self = this
// catch clicks from disabled type='a' button (https://github.com/quasarframework/quasar/issues/9258)
if (!this.isSupported || this.isPermissionDenied) {
return
}
// ask for notification permission
Notification.requestPermission()
.then(permission => {
this.isPermissionGranted = permission === 'granted'
this.isPermissionDenied = permission === 'denied'
})
.catch(function (e) {
console.log(e)
})
// create push subscription
navigator.serviceWorker.ready.then(registration => {
navigator.serviceWorker.getRegistration().then(registration => {
registration.pushManager
.getSubscription()
.then(function (subscription) {
if (
subscription === null ||
!self.isUserSubscribed(self.g.user.id)
) {
const applicationServerKey = self.urlB64ToUint8Array(
self.pubkey
)
const options = {applicationServerKey, userVisibleOnly: true}
registration.pushManager
.subscribe(options)
.then(function (subscription) {
LNbits.api
.request(
'POST',
'/api/v1/webpush',
self.g.user.wallets[0].adminkey,
{
subscription: JSON.stringify(subscription)
}
)
.then(function (response) {
self.saveUserSubscribed(response.data.user)
self.isSubscribed = true
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
})
.catch(function (e) {
console.log(e)
})
})
})
},
unsubscribe() {
var self = this
navigator.serviceWorker.ready
.then(registration => {
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
LNbits.api
.request(
'DELETE',
'/api/v1/webpush?endpoint=' + btoa(subscription.endpoint),
self.g.user.wallets[0].adminkey
)
.then(function () {
self.removeUserSubscribed(self.g.user.id)
self.isSubscribed = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
})
})
.catch(function (e) {
console.log(e)
})
},
checkSupported: function () {
let https = window.location.protocol === 'https:'
let serviceWorkerApi = 'serviceWorker' in navigator
let notificationApi = 'Notification' in window
let pushApi = 'PushManager' in window
this.isSupported = https && serviceWorkerApi && notificationApi && pushApi
if (!this.isSupported) {
console.log(
'Notifications disabled because requirements are not met:',
{
HTTPS: https,
'Service Worker API': serviceWorkerApi,
'Notification API': notificationApi,
'Push API': pushApi
}
)
}
return this.isSupported
},
updateSubscriptionStatus: async function () {
var self = this
await navigator.serviceWorker.ready
.then(registration => {
registration.pushManager.getSubscription().then(subscription => {
self.isSubscribed =
!!subscription && self.isUserSubscribed(self.g.user.id)
})
})
.catch(function (e) {
console.log(e)
})
}
},
created: function () {
this.isPermissionDenied = Notification.permission === 'denied'
if (this.checkSupported()) {
this.updateSubscriptionStatus()
}
}
})

View file

@ -1,4 +1,5 @@
import asyncio
import json
import time
import traceback
import uuid
@ -7,14 +8,18 @@ from typing import Dict, List, Optional
from fastapi.exceptions import HTTPException
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_expired_invoices,
delete_webpush_subscriptions,
get_balance_checks,
get_payments,
get_standalone_payment,
)
from lnbits.core.services import redeem_lnurl_withdraw
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class
from .core import db
@ -204,3 +209,21 @@ async def invoice_callback_dispatcher(checking_id: str):
for chan_name, send_chan in invoice_listeners.items():
logger.trace(f"sse sending to chan: {chan_name}")
await send_chan.put(payment)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8")),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(f"failed sending push notification: {e.response.text}")

View file

@ -70,6 +70,10 @@
>
<span>OFFLINE</span>
</q-badge>
<lnbits-notifications-btn
v-if="g.user"
pubkey="{{ WEBPUSH_PUBKEY }}"
></lnbits-notifications-btn>
<q-btn-dropdown
dense
flat

45
poetry.lock generated
View file

@ -703,6 +703,19 @@ files = [
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
]
[[package]]
name = "http-ece"
version = "1.1.0"
description = "Encrypted Content Encoding for HTTP"
optional = false
python-versions = "*"
files = [
{file = "http_ece-1.1.0.tar.gz", hash = "sha256:932ebc2fa7c216954c320a188ae9c1f04d01e67bec9cdce1bfbc912813b0b4f8"},
]
[package.dependencies]
cryptography = ">=2.5"
[[package]]
name = "httpcore"
version = "0.15.0"
@ -1329,6 +1342,19 @@ files = [
{file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
]
[[package]]
name = "py-vapid"
version = "1.9.0"
description = "Simple VAPID header generation library"
optional = false
python-versions = "*"
files = [
{file = "py-vapid-1.9.0.tar.gz", hash = "sha256:0664ab7899742ef2b287397a4d461ef691ed0cc2f587205128d8cf617ffdb919"},
]
[package.dependencies]
cryptography = ">=2.5"
[[package]]
name = "pycparser"
version = "2.21"
@ -1615,6 +1641,23 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pywebpush"
version = "1.14.0"
description = "WebPush publication library"
optional = false
python-versions = "*"
files = [
{file = "pywebpush-1.14.0.tar.gz", hash = "sha256:6c36e1679268219e693ba940db2bf254c240ca02664de102b7269afc3c545731"},
]
[package.dependencies]
cryptography = ">=2.6.1"
http-ece = ">=1.1.0"
py-vapid = ">=1.7.0"
requests = ">=2.21.0"
six = ">=1.15.0"
[[package]]
name = "pyyaml"
version = "6.0.1"
@ -2272,4 +2315,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.10 | ^3.9"
content-hash = "3b1b73b1df182fae17e692b1ebd6d35a8791ca62df3ece145b05ae7f4465ae16"
content-hash = "d294784e932335e91b4d096f406664b1240d2e20a7820060524bd6ef651d30ec"

View file

@ -34,6 +34,7 @@ async-timeout = "4.0.2"
pyln-client = "23.8"
cashu = "0.9.0"
slowapi = "^0.1.7"
pywebpush = "^1.14.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.2"
@ -91,6 +92,8 @@ module = [
"psycopg2.*",
"pyngrok.*",
"pyln.client.*",
"py_vapid.*",
"pywebpush.*",
]
ignore_missing_imports = "True"