From 4d047d88c2c398439928a0884cca954dc10b42a8 Mon Sep 17 00:00:00 2001 From: Arc Date: Tue, 27 Jan 2026 19:10:29 +0000 Subject: [PATCH 1/5] slop --- __init__.py | 4 +- helpers.py | 16 +++++ migrations.py | 46 ++++++++++++++ models.py | 12 ++++ services.py | 137 ++++++++++++++++++++++++++++++++++++++++- static/embed.js | 108 +++++++++++++++++++++++++++++++- static/embed.vue | 55 +++++++++++++++++ static/index.js | 18 ++++++ static/index.vue | 54 +++++++++++++--- static/public_page.js | 124 ++++++++++++++++++++++++++++++++++++- static/public_page.vue | 130 +++++++++++++++++++++++--------------- views_api.py | 63 ++++++++++++++++++- views_lnurl.py | 110 +++++++++++++++++++++++++++++++++ 13 files changed, 809 insertions(+), 68 deletions(-) create mode 100644 views_lnurl.py diff --git a/__init__.py b/__init__.py index 74a2179..df6c7a5 100644 --- a/__init__.py +++ b/__init__.py @@ -8,10 +8,12 @@ from .tasks import cleanup_empty_chats, wait_for_paid_invoices from .views import chat_generic_router from .views_api import chat_api_router +from .views_lnurl import chat_lnurl_router chat_ext: APIRouter = APIRouter(prefix="/chat", tags=["Chat"]) -chat_ext.include_router(chat_generic_router) +chat_ext.include_router(chat_lnurl_router) chat_ext.include_router(chat_api_router) +chat_ext.include_router(chat_generic_router) chat_static_files = [ diff --git a/helpers.py b/helpers.py index 0dce4a2..9e572e2 100644 --- a/helpers.py +++ b/helpers.py @@ -1,6 +1,22 @@ import re +from fastapi import Request +from lnurl import encode as lnurl_encode + def is_valid_email_address(email: str) -> bool: email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}" return re.fullmatch(email_regex, email) is not None + + +def chat_lnurl_url(req: Request, chat_id: str) -> str: + url = req.url_for("chat.api_lnurl_response", chat_id=chat_id) + url = url.replace(path=url.path) + url_str = str(url) + if url.netloc.endswith(".onion"): + url_str = url_str.replace("https://", "http://") + return url_str + + +def lnurl_encode_chat(req: Request, chat_id: str) -> str: + return str(lnurl_encode(chat_lnurl_url(req, chat_id)).bech32) diff --git a/migrations.py b/migrations.py index 292a4b7..43bcf7a 100644 --- a/migrations.py +++ b/migrations.py @@ -127,3 +127,49 @@ async def m007_chats_public_url(db): ALTER TABLE chat.chats ADD COLUMN public_url TEXT; """ ) + + +async def m008_chat_lnurlp_balance(db): + """ + Add lnurlp toggle to categories and balance to chats. + """ + + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN lnurlp BOOLEAN DEFAULT 0; + """ + ) + await db.execute( + f""" + ALTER TABLE chat.chats ADD COLUMN balance {db.big_int} DEFAULT 0; + """ + ) + + +async def m009_chat_claims(db): + """ + Add chat claim fields. + """ + + await db.execute( + """ + ALTER TABLE chat.chats ADD COLUMN claimed_by_id TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.chats ADD COLUMN claimed_by_name TEXT; + """ + ) + + +async def m010_chat_claim_split(db): + """ + Add claim split percentage to categories. + """ + + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN claim_split REAL DEFAULT 0; + """ + ) diff --git a/models.py b/models.py index a2f7f33..fb208dd 100644 --- a/models.py +++ b/models.py @@ -8,10 +8,12 @@ class CreateCategories(BaseModel): name: str wallet: str | None = None paid: bool | None = False + lnurlp: bool | None = False tips: bool | None = False chars: int | None = None price_chars: float | None = None denomination: str | None = "sat" + claim_split: float | None = 0 notify_telegram: str | None = None notify_nostr: str | None = None notify_email: str | None = None @@ -23,10 +25,12 @@ class Categories(BaseModel): name: str wallet: str | None = None paid: bool | None = False + lnurlp: bool | None = False tips: bool | None = False chars: int | None = None price_chars: float | None = None denomination: str | None = "sat" + claim_split: float | None = 0 notify_telegram: str | None = None notify_nostr: str | None = None notify_email: str | None = None @@ -39,6 +43,7 @@ class PublicCategories(BaseModel): id: str name: str paid: bool | None = False + lnurlp: bool | None = False tips: bool | None = False chars: int | None = None price_chars: float | None = None @@ -49,19 +54,23 @@ class CategoriesFilters(FilterModel): __search_fields__ = [ "name", "paid", + "lnurlp", "tips", "chars", "price_chars", "denomination", + "claim_split", ] __sort_fields__ = [ "name", "paid", + "lnurlp", "tips", "chars", "price_chars", "denomination", + "claim_split", "created_at", "updated_at", ] @@ -98,6 +107,9 @@ class ChatSession(BaseModel): resolved: bool = False unread: bool = True public_url: str | None = None + balance: int = 0 + claimed_by_id: str | None = None + claimed_by_name: str | None = None participants: list[dict] = Field(default_factory=list) messages: list[dict] = Field(default_factory=list) last_message_at: datetime | None = None diff --git a/services.py b/services.py index 35c7b94..b79e5b7 100644 --- a/services.py +++ b/services.py @@ -4,7 +4,9 @@ from lnbits.core.crud.wallets import get_wallets from lnbits.core.models import Payment -from lnbits.core.services import create_invoice, websocket_manager +from lnbits.core.crud.users import get_user +from lnbits.core.crud.wallets import get_wallets +from lnbits.core.services import create_invoice, pay_invoice, websocket_manager from lnbits.core.services.notifications import send_notification from lnbits.helpers import urlsafe_short_hash from lnbits.utils.exchange_rates import fiat_amount_as_satoshis @@ -66,6 +68,63 @@ async def _broadcast_chat(chat_id: str, payload: dict) -> None: logger.warning(f"chat: websocket send failed: {exc}") +async def _broadcast_balance(chat_id: str, balance: int) -> None: + payload = {"type": "balance", "balance": balance} + await _broadcast_chat(chat_id, payload) + await websocket_manager.send(f"chatbalance:{chat_id}", json.dumps(payload)) + + +async def _broadcast_claim(chat_id: str, claimed_by_id: str | None, claimed_by_name: str | None) -> None: + payload = { + "type": "claim", + "claimed_by_id": claimed_by_id, + "claimed_by_name": claimed_by_name, + } + await _broadcast_chat(chat_id, payload) + + +async def _maybe_pay_claim_split( + category: Categories, chat: ChatSession, amount: int +) -> None: + if not chat.claimed_by_id: + return + split = float(category.claim_split or 0) + if split <= 0: + return + split = max(0.0, min(split, 100.0)) + split_amount = math.floor(amount * (split / 100)) + if split_amount <= 0: + return + claimer_wallets = await get_wallets(chat.claimed_by_id) + if not claimer_wallets: + return + category_wallet_id = await _resolve_category_wallet(category) + if not category_wallet_id: + return + try: + claim_invoice = await create_invoice( + wallet_id=claimer_wallets[0].id, + amount=split_amount, + memo=f"Chat claim split for {category.name}", + extra={ + "tag": "chat", + "payment_type": "claim_split", + "chat_id": chat.id, + "categories_id": chat.categories_id, + "claimed_by_id": chat.claimed_by_id, + }, + ) + await pay_invoice( + wallet_id=category_wallet_id, + payment_request=claim_invoice.bolt11, + max_sat=split_amount, + description="Chat claim split", + tag="chat", + ) + except Exception as exc: + logger.warning(f"Chat claim split payment failed: {exc}") + + def _parse_notify_emails(raw: str | None) -> list[str]: if not raw: return [] @@ -195,10 +254,35 @@ async def send_public_message( sender_name = _clean_name(data.sender_name, "anon") _ensure_participant(chat, data.sender_id, sender_name, data.sender_role) + if user_id and chat.claimed_by_id and chat.claimed_by_id != user_id: + claimed_name = chat.claimed_by_name or "another user" + raise ValueError(f"this chat has been claimed by {claimed_name}") + amount = 0 if category.paid and not user_id: amount = await _calculate_amount(category, data.message) + if category.paid and category.lnurlp and amount > 0 and not user_id: + if chat.balance < amount: + raise ValueError("Insufficient balance. Fund the chat to continue.") + chat.balance = max(0, chat.balance - amount) + await _maybe_pay_claim_split(category, chat, amount) + message = ChatMessage( + id=urlsafe_short_hash(), + sender_id=data.sender_id, + sender_name=sender_name, + sender_role=data.sender_role, + message=data.message, + created_at=datetime.now(timezone.utc), + amount=amount, + message_type="message", + ) + if not chat.messages: + await _notify_new_chat(category, chat, base_url, data.message) + await _append_message(chat, message, unread=True) + await _broadcast_balance(chat.id, chat.balance) + return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) + if category.paid and amount > 0 and not user_id: wallet_id = await _resolve_category_wallet(category) if not wallet_id: @@ -355,6 +439,21 @@ async def payment_received_for_client_data(payment: Payment) -> bool: if payment.extra.get("tag") != "chat": return False + if payment.extra.get("payment_type") == "balance": + chat_id = payment.extra.get("chat_id") + if not chat_id: + logger.warning("Chat balance payment missing chat_id.") + return False + chat = await get_chat(chat_id) + if not chat: + logger.warning("Chat not found for balance payment.") + return False + chat.balance = max(0, chat.balance + payment.sat) + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + await _broadcast_balance(chat.id, chat.balance) + return True + chat_payment = await get_chat_payment(payment.payment_hash) if not chat_payment: logger.warning("Chat payment not found.") @@ -371,7 +470,18 @@ async def payment_received_for_client_data(payment: Payment) -> bool: logger.warning("Chat not found for payment.") return False + if chat_payment.payment_type == "balance": + chat.balance = max(0, chat.balance + chat_payment.amount) + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + await _broadcast_balance(chat.id, chat.balance) + return True + message_type = "tip" if chat_payment.payment_type == "tip" else "message" + if chat_payment.payment_type == "message": + category = await get_categories_by_id(chat.categories_id) + if category: + await _maybe_pay_claim_split(category, chat, chat_payment.amount) message = ChatMessage( id=urlsafe_short_hash(), sender_id=chat_payment.sender_id, @@ -388,3 +498,28 @@ async def payment_received_for_client_data(payment: Payment) -> bool: await _notify_new_chat(category, chat, None, chat_payment.message) await _append_message(chat, message, unread=True) return True + + +async def toggle_chat_claim(chat_id: str, user_id: str) -> ChatSession: + chat = await get_chat(chat_id) + if not chat: + raise ValueError("Chat not found.") + + user = await get_user(user_id) + if not user: + raise ValueError("User not found.") + + if chat.claimed_by_id and chat.claimed_by_id != user_id: + raise ValueError(f"this chat has been claimed by {chat.claimed_by_name}") + + if chat.claimed_by_id == user_id: + chat.claimed_by_id = None + chat.claimed_by_name = None + else: + chat.claimed_by_id = user_id + chat.claimed_by_name = user.username or "user" + + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + await _broadcast_claim(chat.id, chat.claimed_by_id, chat.claimed_by_name) + return chat diff --git a/static/embed.js b/static/embed.js index a88cd21..c494798 100644 --- a/static/embed.js +++ b/static/embed.js @@ -11,7 +11,10 @@ window.PageChatEmbed = { id: '', participants: [], messages: [], - resolved: false + resolved: false, + balance: 0, + claimed_by_id: null, + claimed_by_name: null }, publicPageData: {}, sending: false, @@ -25,8 +28,12 @@ window.PageChatEmbed = { showTipDialog: false, tipAmount: null, chatSocket: null, + balanceSocket: null, isMinimized: false, - launcherText: 'Chat to us' + launcherText: 'Chat to us', + lnurlPay: '', + lnurlDialog: false, + authUser: null } }, watch: { @@ -86,6 +93,9 @@ window.PageChatEmbed = { } else if (user) { this.participantName = 'anon' } + if (user?.id) { + this.authUser = user + } } catch (_) { // ignore if not logged in } @@ -114,6 +124,7 @@ window.PageChatEmbed = { ) this.chatId = data.id this.chatData = data + this.updateChatUrl() }, updateChatUrl() { @@ -131,6 +142,47 @@ window.PageChatEmbed = { this.chatData = data }, + async toggleClaim() { + if (!this.authUser) return + try { + const {data} = await LNbits.api.request( + 'POST', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public/claim`, + null + ) + this.chatData = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + async fetchLnurl() { + if (!this.publicPageData?.paid || !this.publicPageData?.lnurlp) return + try { + const {data} = await LNbits.api.request( + 'GET', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/lnurl` + ) + this.lnurlPay = data.url || data.lnurl + } catch (error) { + console.warn(error) + } + }, + + async openLnurlDialog() { + if (!this.lnurlPay) { + await this.fetchLnurl() + } + if (!this.lnurlPay) { + Quasar.Notify.create({ + type: 'negative', + message: 'Unable to load LNURL.' + }) + return + } + this.lnurlDialog = true + }, + async onSendMessage(messageText) { if (!messageText || this.sending) return this.sending = true @@ -305,11 +357,58 @@ window.PageChatEmbed = { if (payload.type === 'resolved') { this.chatData.resolved = payload.resolved } + if (payload.type === 'balance') { + const nextBalance = payload.balance || 0 + const prevBalance = this.chatData.balance || 0 + this.chatData.balance = nextBalance + if (this.lnurlDialog && nextBalance > prevBalance) { + this.lnurlDialog = false + Quasar.Notify.create({ + type: 'positive', + message: 'Balance funded' + }) + } + } + if (payload.type === 'claim') { + this.chatData.claimed_by_id = payload.claimed_by_id + this.chatData.claimed_by_name = payload.claimed_by_name + } } catch (err) { console.warn('Chat websocket message failed', err) } }) this.chatSocket = ws + }, + + connectBalanceWebsocket() { + if (!this.chatId) return + if (this.balanceSocket) { + this.balanceSocket.close() + } + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = `/api/v1/ws/chatbalance:${this.chatId}` + const ws = new WebSocket(url) + ws.addEventListener('message', ({data}) => { + try { + const payload = JSON.parse(data) + if (payload.type === 'balance') { + const nextBalance = payload.balance || 0 + const prevBalance = this.chatData.balance || 0 + this.chatData.balance = nextBalance + if (this.lnurlDialog && nextBalance > prevBalance) { + this.lnurlDialog = false + Quasar.Notify.create({ + type: 'positive', + message: 'Balance funded' + }) + } + } + } catch (err) { + console.warn('Balance websocket message failed', err) + } + }) + this.balanceSocket = ws } }, created: async function () { @@ -320,13 +419,18 @@ window.PageChatEmbed = { await this.fetchPublicData() await this.ensureParticipant() await this.ensureChat() + await this.fetchLnurl() this.connectChatWebsocket() + this.connectBalanceWebsocket() this.notifyParent() }, beforeUnmount() { if (this.chatSocket) { this.chatSocket.close() } + if (this.balanceSocket) { + this.balanceSocket.close() + } } } diff --git a/static/embed.vue b/static/embed.vue index 0355c15..dea8e1c 100644 --- a/static/embed.vue +++ b/static/embed.vue @@ -62,6 +62,16 @@ > Send a tip + + Fund balance + Payment required ( sats) +
+ Balance: sats +
+
+ + Claimed by + + +
@@ -136,6 +169,28 @@ + + + + +
Fund chat balance
+
+ Scan with an LNURL compatible wallet. +
+
+ + + + + + +
+
diff --git a/static/index.js b/static/index.js index 920182a..4b14723 100644 --- a/static/index.js +++ b/static/index.js @@ -10,10 +10,12 @@ window.PageChat = { name: null, wallet: null, paid: false, + lnurlp: false, tips: false, chars: null, price_chars: null, denomination: 'sat', + claim_split: 0, notify_telegram: null, notify_nostr: null, notify_email: null @@ -110,6 +112,16 @@ window.PageChat = { this.getCategories() } }, + 'categoriesFormDialog.data.paid': { + handler(paid) { + if (!paid && this.categoriesFormDialog.data.lnurlp) { + this.categoriesFormDialog.data.lnurlp = false + } + if (!paid && this.categoriesFormDialog.data.claim_split) { + this.categoriesFormDialog.data.claim_split = 0 + } + } + }, 'chatsTable.search': { handler() { this.getChats() @@ -133,10 +145,12 @@ window.PageChat = { name: null, wallet: this.g.user.wallets[0]?.id || null, paid: false, + lnurlp: false, tips: false, chars: null, price_chars: null, denomination: 'sat', + claim_split: 0, notify_telegram: null, notify_nostr: null, notify_email: null @@ -150,6 +164,10 @@ window.PageChat = { async saveCategories() { try { const data = {extra: {}, ...this.categoriesFormDialog.data} + if (!data.paid) { + data.lnurlp = false + data.claim_split = 0 + } const method = data.id ? 'PUT' : 'POST' const entry = data.id ? `/${data.id}` : '' await LNbits.api.request( diff --git a/static/index.vue b/static/index.vue index 5e46aaa..db28b26 100644 --- a/static/index.vue +++ b/static/index.vue @@ -338,17 +338,51 @@ hint="Wallet to receive payments" > - +
+ + +
- + +
+ + + (instead as payg, create an lnurlp users can fund) + + + +
+
this.scrollToBottom()) + if (this.autoScroll) { + setTimeout(() => this.scrollToBottom(), 0) + setTimeout(() => this.scrollToBottom(), 150) + } }, deep: true } }, methods: { + onChatScroll(details) { + const el = this.$refs.chatScroll + if (!el) return + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 + this.autoScroll = atBottom + }, async fetchPublicData() { try { const {data} = await LNbits.api.request( @@ -67,6 +84,9 @@ window.PageChatPublic = { } else if (user) { this.participantName = 'anon' } + if (user?.id) { + this.authUser = user + } } catch (_) { // ignore if not logged in } @@ -95,6 +115,7 @@ window.PageChatPublic = { ) this.chatId = data.id this.chatData = data + this.updateChatUrl() }, updateChatUrl() { @@ -109,6 +130,50 @@ window.PageChatPublic = { `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public` ) this.chatData = data + this.scrollReady = true + setTimeout(() => this.scrollToBottom(), 0) + setTimeout(() => this.scrollToBottom(), 150) + }, + + async toggleClaim() { + if (!this.authUser) return + try { + const {data} = await LNbits.api.request( + 'POST', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public/claim`, + null + ) + this.chatData = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + async fetchLnurl() { + if (!this.publicPageData?.paid || !this.publicPageData?.lnurlp) return + try { + const {data} = await LNbits.api.request( + 'GET', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/lnurl` + ) + this.lnurlPay = data.url || data.lnurl + } catch (error) { + console.warn(error) + } + }, + + async openLnurlDialog() { + if (!this.lnurlPay) { + await this.fetchLnurl() + } + if (!this.lnurlPay) { + Quasar.Notify.create({ + type: 'negative', + message: 'Unable to load LNURL.' + }) + return + } + this.lnurlDialog = true }, async onSendMessage(messageText) { @@ -298,11 +363,58 @@ window.PageChatPublic = { if (payload.type === 'resolved') { this.chatData.resolved = payload.resolved } + if (payload.type === 'balance') { + const nextBalance = payload.balance || 0 + const prevBalance = this.chatData.balance || 0 + this.chatData.balance = nextBalance + if (this.lnurlDialog && nextBalance > prevBalance) { + this.lnurlDialog = false + Quasar.Notify.create({ + type: 'positive', + message: 'Balance funded' + }) + } + } + if (payload.type === 'claim') { + this.chatData.claimed_by_id = payload.claimed_by_id + this.chatData.claimed_by_name = payload.claimed_by_name + } } catch (err) { console.warn('Chat websocket message failed', err) } }) this.chatSocket = ws + }, + + connectBalanceWebsocket() { + if (!this.chatId) return + if (this.balanceSocket) { + this.balanceSocket.close() + } + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = `/api/v1/ws/chatbalance:${this.chatId}` + const ws = new WebSocket(url) + ws.addEventListener('message', ({data}) => { + try { + const payload = JSON.parse(data) + if (payload.type === 'balance') { + const nextBalance = payload.balance || 0 + const prevBalance = this.chatData.balance || 0 + this.chatData.balance = nextBalance + if (this.lnurlDialog && nextBalance > prevBalance) { + this.lnurlDialog = false + Quasar.Notify.create({ + type: 'positive', + message: 'Balance funded' + }) + } + } + } catch (err) { + console.warn('Balance websocket message failed', err) + } + }) + this.balanceSocket = ws } }, created: async function () { @@ -310,12 +422,18 @@ window.PageChatPublic = { await this.fetchPublicData() await this.ensureParticipant() await this.ensureChat() + await this.fetchLnurl() this.connectChatWebsocket() + this.connectBalanceWebsocket() }, + mounted() {}, beforeUnmount() { if (this.chatSocket) { this.chatSocket.close() } + if (this.balanceSocket) { + this.balanceSocket.close() + } } } diff --git a/static/public_page.vue b/static/public_page.vue index 1eec7e1..9ab4cc5 100644 --- a/static/public_page.vue +++ b/static/public_page.vue @@ -5,8 +5,8 @@ - - diff --git a/views_api.py b/views_api.py index e8a66c2..ab8a1e3 100644 --- a/views_api.py +++ b/views_api.py @@ -16,9 +16,11 @@ get_categories_ids_by_user, get_categories_paginated, get_chat, + get_chat_for_category, get_chats_paginated, update_categories, ) +from .helpers import chat_lnurl_url, lnurl_encode_chat from .models import ( Categories, CategoriesFilters, @@ -40,6 +42,7 @@ request_tip, send_admin_message, send_public_message, + toggle_chat_claim, ) categories_filters = parse_filters(CategoriesFilters) @@ -54,7 +57,13 @@ async def api_create_categories( data: CreateCategories, account_id: AccountId = Depends(check_account_id_exists), ) -> Categories: - categories = await create_categories(account_id.id, data) + payload = data.dict() + if not payload.get("paid"): + payload["lnurlp"] = False + payload["claim_split"] = 0 + if payload.get("claim_split") is not None: + payload["claim_split"] = max(0, min(float(payload["claim_split"]), 90)) + categories = await create_categories(account_id.id, CreateCategories(**payload)) return categories @@ -69,7 +78,15 @@ async def api_update_categories( raise HTTPException(HTTPStatus.NOT_FOUND, "Categories not found.") if categories.user_id != account_id.id: raise HTTPException(HTTPStatus.FORBIDDEN, "You do not own this categories.") - categories = await update_categories(Categories(**{**categories.dict(), **data.dict()})) + payload = data.dict() + if not payload.get("paid"): + payload["lnurlp"] = False + payload["claim_split"] = 0 + if payload.get("claim_split") is not None: + payload["claim_split"] = max(0, min(float(payload["claim_split"]), 90)) + categories = await update_categories( + Categories(**{**categories.dict(), **payload}) + ) return categories @@ -181,6 +198,26 @@ async def api_get_public_chat(categories_id: str, chat_id: str) -> ChatSession: raise HTTPException(HTTPStatus.NOT_FOUND, str(exc)) from exc +@chat_api_router.get( + "/api/v1/chats/{categories_id}/{chat_id}/lnurl", + name="Get Chat LNURL", + summary="Get LNURL for chat balance funding.", +) +async def api_get_chat_lnurl( + categories_id: str, chat_id: str, request: Request +) -> dict: + chat = await get_chat_for_category(categories_id, chat_id) + if not chat: + raise HTTPException(HTTPStatus.NOT_FOUND, "Chat not found.") + categories = await get_categories_by_id(categories_id) + if not categories or not categories.paid or not categories.lnurlp: + raise HTTPException(HTTPStatus.NOT_FOUND, "Chat does not accept balance.") + return { + "lnurl": lnurl_encode_chat(request, chat.id), + "url": chat_lnurl_url(request, chat.id), + } + + @chat_api_router.post( "/api/v1/chats/{categories_id}/{chat_id}/public/messages", name="Send Message (Public)", @@ -201,6 +238,28 @@ async def api_send_public_message( raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc +@chat_api_router.post( + "/api/v1/chats/{categories_id}/{chat_id}/public/claim", + name="Toggle Chat Claim", + summary="Claim or release a chat (public endpoint, logged-in users only).", + response_model=ChatSession, +) +async def api_toggle_chat_claim( + categories_id: str, + chat_id: str, + user_id: str | None = Depends(optional_user_id), +) -> ChatSession: + if not user_id: + raise HTTPException(HTTPStatus.UNAUTHORIZED, "Login required.") + chat = await get_chat_for_category(categories_id, chat_id) + if not chat: + raise HTTPException(HTTPStatus.NOT_FOUND, "Chat not found.") + try: + return await toggle_chat_claim(chat_id, user_id) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + @chat_api_router.post( "/api/v1/chats/{categories_id}/{chat_id}/public/tip", name="Send Tip (Public)", diff --git a/views_lnurl.py b/views_lnurl.py new file mode 100644 index 0000000..09cb9c2 --- /dev/null +++ b/views_lnurl.py @@ -0,0 +1,110 @@ +import json +from http import HTTPStatus + +from fastapi import APIRouter, HTTPException, Query, Request +from lnbits.core.services import create_invoice +from lnbits.settings import settings +from lnurl import ( + CallbackUrl, + LightningInvoice, + LnurlErrorResponse, + LnurlPayActionResponse, + LnurlPayMetadata, + LnurlPayResponse, + MilliSatoshi, +) +from pydantic import parse_obj_as + +from .crud import get_categories_by_id, get_chat +from .services import _resolve_category_wallet + +chat_lnurl_router = APIRouter() + + +def _chat_lnurl_limits_msat() -> tuple[int, int]: + minimum = 1_000 + maximum = settings.lnbits_max_incoming_payment_amount_sats * 1000 + return minimum, maximum + + +@chat_lnurl_router.get( + "/api/v1/lnurl/cb/{chat_id}", + status_code=HTTPStatus.OK, + name="chat.api_lnurl_callback", +) +async def api_lnurl_callback( + request: Request, + chat_id: str, + amount: int = Query(...), +) -> LnurlErrorResponse | LnurlPayActionResponse: + chat = await get_chat(chat_id) + if not chat: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Chat does not exist." + ) + category = await get_categories_by_id(chat.categories_id) + if not category or not category.paid or not category.lnurlp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Chat does not accept balance." + ) + + minimum, maximum = _chat_lnurl_limits_msat() + if amount < minimum: + return LnurlErrorResponse( + reason=f"Amount {amount} is smaller than minimum {minimum}." + ) + if amount > maximum: + return LnurlErrorResponse( + reason=f"Amount {amount} is greater than maximum {maximum}." + ) + + wallet_id = await _resolve_category_wallet(category) + if not wallet_id: + return LnurlErrorResponse(reason="Category wallet not configured.") + + amount_sat = int(amount / 1000) + payment = await create_invoice( + wallet_id=wallet_id, + amount=amount_sat, + memo=f"Chat balance for {category.name}", + extra={ + "tag": "chat", + "payment_type": "balance", + "chat_id": chat.id, + "categories_id": chat.categories_id, + }, + ) + invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11)) + return LnurlPayActionResponse(pr=invoice, disposable=False) + + +@chat_lnurl_router.get( + "/lnurl/{chat_id}", + name="chat.api_lnurl_response", +) +async def api_lnurl_response(request: Request, chat_id: str) -> LnurlPayResponse: + chat = await get_chat(chat_id) + if not chat: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Chat does not exist." + ) + category = await get_categories_by_id(chat.categories_id) + if not category or not category.paid or not category.lnurlp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Chat does not accept balance." + ) + + url = request.url_for("chat.api_lnurl_callback", chat_id=chat.id) + callback_url = parse_obj_as(CallbackUrl, str(url)) + + metadata = LnurlPayMetadata( + json.dumps([["text/plain", f"Chat balance for {category.name}"]]) + ) + minimum, maximum = _chat_lnurl_limits_msat() + + return LnurlPayResponse( + callback=callback_url, + minSendable=MilliSatoshi(minimum), + maxSendable=MilliSatoshi(maximum), + metadata=metadata, + ) From 2a1ebc2acbc051b6e49941dd609b297bf7eacb32 Mon Sep 17 00:00:00 2001 From: Arc Date: Tue, 27 Jan 2026 23:27:57 +0000 Subject: [PATCH 2/5] fixed chat --- static/public_page.js | 87 ++++++++++++++++++++----- static/public_page.vue | 144 ++++++++++++++++++++++------------------- 2 files changed, 148 insertions(+), 83 deletions(-) diff --git a/static/public_page.js b/static/public_page.js index c13c93f..d50c7d8 100644 --- a/static/public_page.js +++ b/static/public_page.js @@ -35,24 +35,68 @@ window.PageChatPublic = { autoScroll: true } }, + watch: { 'chatData.messages': { - handler() { - if (this.autoScroll) { - setTimeout(() => this.scrollToBottom(), 0) - setTimeout(() => this.scrollToBottom(), 150) - } + async handler() { + if (!this.autoScroll) return + await this.scrollToBottomSmooth() }, deep: true } }, + methods: { + // --- NEW: always get the actual DOM element that scrolls --- + getChatScrollEl() { + const ref = this.$refs.chatScroll + if (!ref) return null + // If ref is a Quasar/Vue component, the real element is at $el + return ref.$el ? ref.$el : ref + }, + onChatScroll(details) { - const el = this.$refs.chatScroll + const el = this.getChatScrollEl() if (!el) return + + // If there's nothing to scroll, treat as "at bottom" + if (el.scrollHeight <= el.clientHeight + 8) { + this.autoScroll = true + return + } + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 this.autoScroll = atBottom }, + + async scrollToBottomSmooth() { + const el = this.getChatScrollEl() + if (!el) return + + // wait for Vue to render messages + await this.$nextTick() + + // then wait for browser layout/paint + requestAnimationFrame(() => { + const el2 = this.getChatScrollEl() + if (!el2) return + el2.scrollTop = el2.scrollHeight + }) + + // one more frame helps with fonts/images/layout shifts + requestAnimationFrame(() => { + const el3 = this.getChatScrollEl() + if (!el3) return + el3.scrollTop = el3.scrollHeight + }) + }, + + scrollToBottom() { + const el = this.getChatScrollEl() + if (!el) return + el.scrollTop = el.scrollHeight + }, + async fetchPublicData() { try { const {data} = await LNbits.api.request( @@ -116,6 +160,9 @@ window.PageChatPublic = { this.chatId = data.id this.chatData = data this.updateChatUrl() + + this.autoScroll = true + await this.scrollToBottomSmooth() }, updateChatUrl() { @@ -131,8 +178,9 @@ window.PageChatPublic = { ) this.chatData = data this.scrollReady = true - setTimeout(() => this.scrollToBottom(), 0) - setTimeout(() => this.scrollToBottom(), 150) + + this.autoScroll = true + await this.scrollToBottomSmooth() }, async toggleClaim() { @@ -215,6 +263,11 @@ window.PageChatPublic = { if (!messageText) return this.messageInput = '' await this.onSendMessage(messageText) + + // if user is at bottom, keep them at bottom after sending + if (this.autoScroll) { + await this.scrollToBottomSmooth() + } }, isSent(message) { @@ -257,12 +310,6 @@ window.PageChatPublic = { return Math.abs(hash) }, - scrollToBottom() { - const container = this.$refs.chatScroll - if (!container) return - container.scrollTop = container.scrollHeight - }, - dateFromNow(date) { return moment(date).fromNow() }, @@ -313,6 +360,9 @@ window.PageChatPublic = { message: 'Payment received' }) ws.close() + if (this.autoScroll) { + await this.scrollToBottomSmooth() + } } }) } catch (err) { @@ -417,6 +467,7 @@ window.PageChatPublic = { this.balanceSocket = ws } }, + created: async function () { this.categoriesId = this.$route.params.id await this.fetchPublicData() @@ -426,7 +477,13 @@ window.PageChatPublic = { this.connectChatWebsocket() this.connectBalanceWebsocket() }, - mounted() {}, + + mounted() { + // One extra nudge once the DOM exists + this.autoScroll = true + this.scrollToBottomSmooth() + }, + beforeUnmount() { if (this.chatSocket) { this.chatSocket.close() diff --git a/static/public_page.vue b/static/public_page.vue index 9ab4cc5..4f2e6b0 100644 --- a/static/public_page.vue +++ b/static/public_page.vue @@ -3,77 +3,85 @@ diff --git a/views_api.py b/views_api.py index ab8a1e3..2744279 100644 --- a/views_api.py +++ b/views_api.py @@ -141,7 +141,9 @@ async def api_get_public_categories(categories_id: str) -> PublicCategories: if not categories: raise HTTPException(HTTPStatus.NOT_FOUND, "Categories not found.") - return PublicCategories(**categories.dict()) + payload = categories.dict() + payload["claim_split"] = categories.claim_split or 0 + return PublicCategories(**payload) @chat_api_router.delete( From 70ebb051071f64d8fd687995adf586a1df456964 Mon Sep 17 00:00:00 2001 From: Arc Date: Wed, 28 Jan 2026 01:17:13 +0000 Subject: [PATCH 5/5] cleanup --- services.py | 227 ++++++++++++++++++++++++----------------- static/embed.js | 53 ++++++---- static/embed.vue | 5 +- static/index.js | 5 +- static/index.vue | 8 +- static/public_page.js | 44 ++++---- static/public_page.vue | 144 +++++++++++++------------- views.py | 3 - views_api.py | 11 +- views_lnurl.py | 28 ++--- 10 files changed, 278 insertions(+), 250 deletions(-) diff --git a/services.py b/services.py index fbd5291..a6bdc2f 100644 --- a/services.py +++ b/services.py @@ -2,10 +2,9 @@ import math from datetime import datetime, timezone -from lnbits.core.crud.wallets import get_wallets -from lnbits.core.models import Payment from lnbits.core.crud.users import get_user from lnbits.core.crud.wallets import get_wallets +from lnbits.core.models import Payment from lnbits.core.services import create_invoice, pay_invoice, websocket_manager from lnbits.core.services.notifications import send_notification from lnbits.helpers import urlsafe_short_hash @@ -83,9 +82,7 @@ async def _broadcast_claim(chat_id: str, claimed_by_id: str | None, claimed_by_n await _broadcast_chat(chat_id, payload) -async def _maybe_pay_claim_split( - category: Categories, chat: ChatSession, amount: int -) -> None: +async def _maybe_pay_claim_split(category: Categories, chat: ChatSession, amount: int) -> None: if not chat.claimed_by_id: return split = float(category.claim_split or 0) @@ -239,6 +236,103 @@ async def _calculate_amount(category: Categories, message: str) -> int: return math.ceil(raw_amount) +async def _handle_lnurlp_drawdown( + category: Categories, + chat: ChatSession, + amount: int, + data: CreateChatMessage, + sender_name: str, + base_url: str | None, +) -> ChatPaymentRequest: + if chat.balance < amount: + raise ValueError("Insufficient balance. Fund the chat to continue.") + chat.balance = max(0, chat.balance - amount) + await _maybe_pay_claim_split(category, chat, amount) + message = ChatMessage( + id=urlsafe_short_hash(), + sender_id=data.sender_id, + sender_name=sender_name, + sender_role=data.sender_role, + message=data.message, + created_at=datetime.now(timezone.utc), + amount=amount, + message_type="message", + ) + if not chat.messages: + await _notify_new_chat(category, chat, base_url, data.message) + await _append_message(chat, message, unread=True) + await _broadcast_balance(chat.id, chat.balance) + return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) + + +async def _create_payg_payment_request( + category: Categories, + chat: ChatSession, + amount: int, + data: CreateChatMessage, + sender_name: str, +) -> ChatPaymentRequest: + wallet_id = await _resolve_category_wallet(category) + if not wallet_id: + raise ValueError("Category wallet not configured.") + payment = await create_invoice( + wallet_id=wallet_id, + amount=amount, + memo=f"Chat message for {category.name}", + extra={ + "tag": "chat", + "chat_id": chat.id, + "categories_id": chat.categories_id, + "sender_id": data.sender_id, + "sender_name": sender_name, + "sender_role": data.sender_role, + "message": data.message, + "payment_type": "message", + }, + ) + await create_chat_payment( + ChatPayment( + payment_hash=payment.payment_hash, + chat_id=chat.id, + categories_id=chat.categories_id, + sender_id=data.sender_id, + sender_name=sender_name, + sender_role=data.sender_role, + message=data.message, + amount=amount, + payment_type="message", + ) + ) + return ChatPaymentRequest( + chat_id=chat.id, + payment_hash=payment.payment_hash, + payment_request=payment.bolt11, + amount=amount, + pending=True, + ) + + +async def _send_free_message( + category: Categories, + chat: ChatSession, + data: CreateChatMessage, + sender_name: str, + base_url: str | None, +) -> ChatPaymentRequest: + message = ChatMessage( + id=urlsafe_short_hash(), + sender_id=data.sender_id, + sender_name=sender_name, + sender_role=data.sender_role, + message=data.message, + created_at=datetime.now(timezone.utc), + ) + if not chat.messages: + await _notify_new_chat(category, chat, base_url, data.message) + await _append_message(chat, message, unread=True) + return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) + + async def send_public_message( categories_id: str, chat_id: str, @@ -267,78 +361,12 @@ async def send_public_message( amount = await _calculate_amount(category, data.message) if category.paid and category.lnurlp and amount > 0 and not user_id: - if chat.balance < amount: - raise ValueError("Insufficient balance. Fund the chat to continue.") - chat.balance = max(0, chat.balance - amount) - await _maybe_pay_claim_split(category, chat, amount) - message = ChatMessage( - id=urlsafe_short_hash(), - sender_id=data.sender_id, - sender_name=sender_name, - sender_role=data.sender_role, - message=data.message, - created_at=datetime.now(timezone.utc), - amount=amount, - message_type="message", - ) - if not chat.messages: - await _notify_new_chat(category, chat, base_url, data.message) - await _append_message(chat, message, unread=True) - await _broadcast_balance(chat.id, chat.balance) - return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) + return await _handle_lnurlp_drawdown(category, chat, amount, data, sender_name, base_url) if category.paid and amount > 0 and not user_id: - wallet_id = await _resolve_category_wallet(category) - if not wallet_id: - raise ValueError("Category wallet not configured.") - payment = await create_invoice( - wallet_id=wallet_id, - amount=amount, - memo=f"Chat message for {category.name}", - extra={ - "tag": "chat", - "chat_id": chat.id, - "categories_id": categories_id, - "sender_id": data.sender_id, - "sender_name": sender_name, - "sender_role": data.sender_role, - "message": data.message, - "payment_type": "message", - }, - ) - await create_chat_payment( - ChatPayment( - payment_hash=payment.payment_hash, - chat_id=chat.id, - categories_id=categories_id, - sender_id=data.sender_id, - sender_name=sender_name, - sender_role=data.sender_role, - message=data.message, - amount=amount, - payment_type="message", - ) - ) - return ChatPaymentRequest( - chat_id=chat.id, - payment_hash=payment.payment_hash, - payment_request=payment.bolt11, - amount=amount, - pending=True, - ) + return await _create_payg_payment_request(category, chat, amount, data, sender_name) - message = ChatMessage( - id=urlsafe_short_hash(), - sender_id=data.sender_id, - sender_name=sender_name, - sender_role=data.sender_role, - message=data.message, - created_at=datetime.now(timezone.utc), - ) - if not chat.messages: - await _notify_new_chat(category, chat, base_url, data.message) - await _append_message(chat, message, unread=True) - return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) + return await _send_free_message(category, chat, data, sender_name, base_url) async def send_admin_message( @@ -439,30 +467,22 @@ async def request_tip( ) -async def payment_received_for_client_data(payment: Payment) -> bool: - if payment.extra.get("tag") != "chat": +async def _apply_balance_payment(chat_id: str | None, amount_sat: int) -> bool: + if not chat_id: + logger.warning("Chat balance payment missing chat_id.") return False - - if payment.extra.get("payment_type") == "balance": - chat_id = payment.extra.get("chat_id") - if not chat_id: - logger.warning("Chat balance payment missing chat_id.") - return False - chat = await get_chat(chat_id) - if not chat: - logger.warning("Chat not found for balance payment.") - return False - chat.balance = max(0, chat.balance + payment.sat) - chat.updated_at = datetime.now(timezone.utc) - await update_chat(chat) - await _broadcast_balance(chat.id, chat.balance) - return True - - chat_payment = await get_chat_payment(payment.payment_hash) - if not chat_payment: - logger.warning("Chat payment not found.") + chat = await get_chat(chat_id) + if not chat: + logger.warning("Chat not found for balance payment.") return False + chat.balance = max(0, chat.balance + amount_sat) + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + await _broadcast_balance(chat.id, chat.balance) + return True + +async def _finalize_chat_payment(chat_payment: ChatPayment) -> bool: if chat_payment.paid: return True @@ -496,7 +516,7 @@ async def payment_received_for_client_data(payment: Payment) -> bool: amount=chat_payment.amount, message_type=message_type, ) - if not chat.messages or len(chat.messages) == 0: + if not chat.messages: category = await get_categories_by_id(chat.categories_id) if category: await _notify_new_chat(category, chat, None, chat_payment.message) @@ -504,6 +524,21 @@ async def payment_received_for_client_data(payment: Payment) -> bool: return True +async def payment_received_for_client_data(payment: Payment) -> bool: + if payment.extra.get("tag") != "chat": + return False + + if payment.extra.get("payment_type") == "balance": + return await _apply_balance_payment(payment.extra.get("chat_id"), payment.sat) + + chat_payment = await get_chat_payment(payment.payment_hash) + if not chat_payment: + logger.warning("Chat payment not found.") + return False + + return await _finalize_chat_payment(chat_payment) + + async def toggle_chat_claim(chat_id: str, user_id: str) -> ChatSession: chat = await get_chat(chat_id) if not chat: diff --git a/static/embed.js b/static/embed.js index d7fd8f0..f72cea3 100644 --- a/static/embed.js +++ b/static/embed.js @@ -178,6 +178,34 @@ window.PageChatEmbed = { } }, + async refreshBalance() { + if (!this.chatId) return + try { + const {data} = await LNbits.api.request( + 'GET', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public` + ) + if (data && typeof data.balance !== 'undefined') { + this.applyBalanceUpdate(data.balance) + } + } catch (error) { + console.warn(error) + } + }, + + applyBalanceUpdate(nextBalance) { + const next = nextBalance || 0 + const prev = this.chatData.balance || 0 + this.chatData.balance = next + if (this.lnurlDialog && next > prev) { + this.lnurlDialog = false + Quasar.Notify.create({ + type: 'positive', + message: 'Balance funded' + }) + } + }, + async openLnurlDialog() { if (!this.lnurlPay) { await this.fetchLnurl() @@ -367,16 +395,7 @@ window.PageChatEmbed = { this.chatData.resolved = payload.resolved } if (payload.type === 'balance') { - const nextBalance = payload.balance || 0 - const prevBalance = this.chatData.balance || 0 - this.chatData.balance = nextBalance - if (this.lnurlDialog && nextBalance > prevBalance) { - this.lnurlDialog = false - Quasar.Notify.create({ - type: 'positive', - message: 'Balance funded' - }) - } + this.applyBalanceUpdate(payload.balance) } if (payload.type === 'claim') { this.chatData.claimed_by_id = payload.claimed_by_id @@ -398,20 +417,14 @@ window.PageChatEmbed = { url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.pathname = `/api/v1/ws/chatbalance:${this.chatId}` const ws = new WebSocket(url) + ws.addEventListener('open', () => { + this.refreshBalance() + }) ws.addEventListener('message', ({data}) => { try { const payload = JSON.parse(data) if (payload.type === 'balance') { - const nextBalance = payload.balance || 0 - const prevBalance = this.chatData.balance || 0 - this.chatData.balance = nextBalance - if (this.lnurlDialog && nextBalance > prevBalance) { - this.lnurlDialog = false - Quasar.Notify.create({ - type: 'positive', - message: 'Balance funded' - }) - } + this.applyBalanceUpdate(payload.balance) } } catch (err) { console.warn('Balance websocket message failed', err) diff --git a/static/embed.vue b/static/embed.vue index 8055a78..4f721b7 100644 --- a/static/embed.vue +++ b/static/embed.vue @@ -194,7 +194,10 @@
- +
diff --git a/static/index.js b/static/index.js index 8c2d08d..7fe0c5c 100644 --- a/static/index.js +++ b/static/index.js @@ -91,7 +91,6 @@ window.PageChat = { chatSocket: null, messageInput: '', sending: false, - adminParticipantId: '', poller: null, autoScroll: true, embedDialog: { @@ -155,8 +154,7 @@ window.PageChat = { this.autoScroll = true return } - this.autoScroll = - el.scrollTop + el.clientHeight >= el.scrollHeight - 8 + this.autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 }, async scrollToBottomSmooth() { @@ -504,7 +502,6 @@ window.PageChat = { } }, async created() { - this.adminParticipantId = `admin-${this.g.user.id}` await this.fetchCurrencies() await this.getCategories() await this.getChats() diff --git a/static/index.vue b/static/index.vue index ff2e280..6470c3c 100644 --- a/static/index.vue +++ b/static/index.vue @@ -221,9 +221,9 @@
- +
@@ -259,11 +259,11 @@ -
+
prevBalance) { - this.lnurlDialog = false - Quasar.Notify.create({ - type: 'positive', - message: 'Balance funded' - }) - } + this.applyBalanceUpdate(payload.balance) } if (payload.type === 'claim') { this.chatData.claimed_by_id = payload.claimed_by_id @@ -441,13 +444,14 @@ window.PageChatPublic = { url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.pathname = `/api/v1/ws/chatbalance:${this.chatId}` const ws = new WebSocket(url) + ws.addEventListener('open', () => { + this.refreshBalance() + }) ws.addEventListener('message', ({data}) => { try { const payload = JSON.parse(data) if (payload.type === 'balance') { - const nextBalance = payload.balance || 0 - const prevBalance = this.chatData.balance || 0 - this.chatData.balance = nextBalance + this.applyBalanceUpdate(payload.balance) } } catch (err) { console.warn('Balance websocket message failed', err) diff --git a/static/public_page.vue b/static/public_page.vue index 783eda8..d18dc6f 100644 --- a/static/public_page.vue +++ b/static/public_page.vue @@ -3,85 +3,85 @@