diff --git a/lnbits/app.py b/lnbits/app.py index 657fa13bb..d89f87b10 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -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() diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index dc2ad6dd9..cdf4f2e8c 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -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,) + ) diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index ba1416eaf..9cc889133 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -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") + ); + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index e4c97dcc6..0e6e5ac4f 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -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 diff --git a/lnbits/core/services.py b/lnbits/core/services.py index c14271219..5dab75f93 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -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: diff --git a/lnbits/core/static/js/service-worker.js b/lnbits/core/static/js/service-worker.js index 5e2b8b6d4..d243dfdd9 100644 --- a/lnbits/core/static/js/service-worker.js +++ b/lnbits/core/static/js/service-worker.js @@ -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)) +}) diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index f2a8ce6ad..79405af47 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -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) diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py index f6ed18d00..1de025b6d 100644 --- a/lnbits/core/views/admin_api.py +++ b/lnbits/core/views/admin_api.py @@ -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." diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 59ef4d5b7..fbabf1166 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -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) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index f07b10ad8..630158f47 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -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}"}], } diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 59252d795..c4f639158 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -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 diff --git a/lnbits/settings.py b/lnbits/settings.py index 0a12c8364..9967efc20 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -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", diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js index 15865a20d..fc098ef71 100644 --- a/lnbits/static/js/components.js +++ b/lnbits/static/js/components.js @@ -215,38 +215,38 @@ Vue.component('lnbits-payment-details', { }, template: `
- +
#{{ payment.tag }}
- +
: {{ payment.date }} ({{ payment.dateFrom }})
- +
: {{ payment.expirydate }} ({{ payment.expirydateFrom }})
- +
: {{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
- +
: {{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
- +
: {{ payment.payment_hash }}
- +
: {{ payment.memo }}
@@ -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: ` + + Subscribe to notifications + Unsubscribe from notifications + + Notifications are disabled,
please enable or reset permissions +
+ Notifications are not supported +
+ `, + 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() + } + } +}) diff --git a/lnbits/tasks.py b/lnbits/tasks.py index db4c1f1a8..6026b1a5a 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -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}") diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index e5dc93d81..d6cf155b2 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -70,6 +70,10 @@ > OFFLINE + =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" diff --git a/pyproject.toml b/pyproject.toml index 3ce95a395..92dad9419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"