From 0b624183104aaa83897c3ce7224dd32475ac6e8d Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 5 Aug 2025 20:34:04 +0200 Subject: [PATCH] feat: Add Nostr chat integration for LNBits users - Introduce a new chat system that allows LNBits users to communicate via Nostr relays. - Implement ChatComponent for real-time messaging, peer selection, and message display. - Create useNostrChat composable to manage Nostr relay connections, message encryption, and user authentication. - Develop ChatPage to serve as the main interface for the chat feature. - Add API endpoints for retrieving current user and public keys for peer messaging. - Ensure secure communication with encryption and admin-only access to private keys. --- CHAT_INTEGRATION.md | 156 +++++++++++++ src/components/nostr/ChatComponent.vue | 291 ++++++++++++++++++++++++ src/composables/useNostrChat.ts | 301 +++++++++++++++++++++++++ src/pages/ChatPage.vue | 22 ++ src/router/index.ts | 9 + 5 files changed, 779 insertions(+) create mode 100644 CHAT_INTEGRATION.md create mode 100644 src/components/nostr/ChatComponent.vue create mode 100644 src/composables/useNostrChat.ts create mode 100644 src/pages/ChatPage.vue diff --git a/CHAT_INTEGRATION.md b/CHAT_INTEGRATION.md new file mode 100644 index 0000000..34e1dc1 --- /dev/null +++ b/CHAT_INTEGRATION.md @@ -0,0 +1,156 @@ +# Nostr Chat Integration for Web-App + +This document describes the Nostr chat integration that allows LNBits users to chat with each other using Nostr relays. + +## Overview + +The chat system integrates with the LNBits user system and Nostr relays to provide encrypted messaging between users. Each user has a Nostr keypair (stored in `pubkey` and `prvkey` fields) that enables secure communication. + +## Components + +### 1. ChatComponent.vue +**Location**: `src/components/nostr/ChatComponent.vue` + +A Vue component that provides the chat interface with: +- Peer list populated from LNBits users +- Real-time messaging using Nostr relays +- Encrypted message exchange +- Connection status indicators + +### 2. useNostrChat.ts +**Location**: `src/composables/useNostrChat.ts` + +A composable that handles: +- Nostr relay connections +- Message encryption/decryption +- User authentication with LNBits +- Real-time message subscription + +### 3. ChatPage.vue +**Location**: `src/pages/ChatPage.vue` + +A page that integrates the chat component into the web-app. + +## API Endpoints + +### Get Current User +```bash +GET /users/api/v1/user/me +Authorization: Bearer + +Response: +{ + "id": "user_id", + "username": "username", + "email": "email@example.com", + "pubkey": "nostr_public_key", + "prvkey": "nostr_private_key", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" +} +``` + +### Get All User Public Keys +```bash +GET /users/api/v1/nostr/pubkeys +Authorization: Bearer + +Response: +[ + { + "user_id": "user_id", + "username": "username", + "pubkey": "nostr_public_key" + } +] +``` + +## Features + +### 1. User Integration +- Automatically loads peers from LNBits user database +- Uses existing `pubkey` and `prvkey` fields +- Admin-only access to private keys for messaging + +### 2. Nostr Relay Integration +- Connects to multiple Nostr relays for redundancy +- Real-time message delivery +- Encrypted end-to-end messaging + +### 3. UI Features +- Peer list with user avatars and names +- Real-time message display +- Connection status indicators +- Message timestamps +- Auto-scroll to latest messages + +## Security + +1. **Encryption**: All messages are encrypted using NIP-04 (Nostr encrypted direct messages) +2. **Private Key Access**: Only admin users can access private keys for messaging +3. **Relay Security**: Messages are distributed across multiple relays for redundancy +4. **User Authentication**: Requires LNBits authentication to access chat + +## Setup Requirements + +1. **NostrTools**: The web-app needs NostrTools loaded globally +2. **Admin Access**: Users need admin privileges to access private keys +3. **Relay Configuration**: Default relays are configured in the composable +4. **LNBits Integration**: Requires the updated LNBits API endpoints + +## Usage + +1. Navigate to `/chat` in the web-app +2. The system will automatically load peers from LNBits +3. Select a peer to start chatting +4. Messages are encrypted and sent via Nostr relays + +## Configuration + +### Default Relays +The system connects to these relays by default: +- `wss://nostr.atitlan.io` +- `wss://relay.damus.io` +- `wss://nos.lol` + +### Relay Configuration +You can modify the relays in `useNostrChat.ts`: +```typescript +const DEFAULT_RELAYS: NostrRelayConfig[] = [ + { url: 'wss://your-relay.com', read: true, write: true } +] +``` + +## Future Enhancements + +1. **Message Persistence**: Store messages locally for offline access +2. **File Sharing**: Support for encrypted file sharing +3. **Group Chats**: Multi-user encrypted conversations +4. **Message Search**: Search through conversation history +5. **Push Notifications**: Real-time notifications for new messages +6. **Profile Integration**: Display user profiles and avatars +7. **Message Reactions**: Support for message reactions and replies + +## Troubleshooting + +### Common Issues + +1. **Connection Failed**: Check relay availability and network connectivity +2. **Messages Not Sending**: Verify user has admin privileges and private key access +3. **Peers Not Loading**: Check LNBits API endpoint and authentication +4. **Encryption Errors**: Ensure NostrTools is properly loaded + +### Debug Information + +The chat component logs detailed information to the console: +- Connection status +- Message encryption/decryption +- Relay connection attempts +- API call results + +## Dependencies + +- **NostrTools**: For Nostr protocol implementation +- **Vue 3**: For reactive UI components +- **LNBits API**: For user management and authentication +- **Nostr Relays**: For message distribution \ No newline at end of file diff --git a/src/components/nostr/ChatComponent.vue b/src/components/nostr/ChatComponent.vue new file mode 100644 index 0000000..94c0d15 --- /dev/null +++ b/src/components/nostr/ChatComponent.vue @@ -0,0 +1,291 @@ + + + \ No newline at end of file diff --git a/src/composables/useNostrChat.ts b/src/composables/useNostrChat.ts new file mode 100644 index 0000000..4133fb2 --- /dev/null +++ b/src/composables/useNostrChat.ts @@ -0,0 +1,301 @@ +import { ref, computed, readonly } from 'vue' +import { useNostrStore } from '@/stores/nostr' + +// Types +export interface ChatMessage { + id: string + content: string + created_at: number + sent: boolean + pubkey: string +} + +export interface NostrRelayConfig { + url: string + read?: boolean + write?: boolean +} + +// Default relays - you can configure these +const DEFAULT_RELAYS: NostrRelayConfig[] = [ + { url: 'wss://nostr.atitlan.io', read: true, write: true }, + { url: 'wss://relay.damus.io', read: true, write: true }, + { url: 'wss://nos.lol', read: true, write: true } +] + +export function useNostrChat() { + const nostrStore = useNostrStore() + + // State + const isConnected = ref(false) + const messages = ref>(new Map()) + const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null) + const connectedRelays = ref([]) + const processedMessageIds = ref(new Set()) + + // Computed + const isLoggedIn = computed(() => !!currentUser.value) + + // Initialize NostrTools + const waitForNostrTools = async (timeout = 5000): Promise => { + const start = Date.now() + + while (Date.now() - start < timeout) { + if (window.NostrTools) { + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error('NostrTools failed to load within timeout period') + } + + // Connect to relays + const connectToRelay = async (url: string): Promise => { + try { + const relay = window.NostrTools.relayInit(url) + await relay.connect() + console.log(`Connected to relay: ${url}`) + return relay + } catch (error) { + console.error(`Failed to connect to ${url}:`, error) + return null + } + } + + // Connect to all relays + const connect = async () => { + try { + await waitForNostrTools() + + // Get current user from LNBits + await loadCurrentUser() + + if (!currentUser.value) { + throw new Error('No user logged in') + } + + // Connect to relays + const relays = await Promise.all( + DEFAULT_RELAYS.map(relay => connectToRelay(relay.url)) + ) + + connectedRelays.value = relays.filter(relay => relay !== null) + isConnected.value = true + + console.log(`Connected to ${connectedRelays.value.length} relays`) + } catch (error) { + console.error('Failed to connect:', error) + throw error + } + } + + // Disconnect from relays + const disconnect = () => { + connectedRelays.value.forEach(relay => { + try { + relay.close() + } catch (error) { + console.error('Error closing relay:', error) + } + }) + + connectedRelays.value = [] + isConnected.value = false + messages.value.clear() + processedMessageIds.value.clear() + } + + // Load current user from LNBits + const loadCurrentUser = async () => { + try { + // Get current user from LNBits API + const response = await fetch('/users/api/v1/user/me', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('admin_token')}` + } + }) + + if (response.ok) { + const user = await response.json() + currentUser.value = { + pubkey: user.pubkey, + prvkey: user.prvkey // This should be available if user is admin + } + } else { + throw new Error('Failed to load current user') + } + } catch (error) { + console.error('Failed to load current user:', error) + throw error + } + } + + // Subscribe to messages from a specific peer + const subscribeToPeer = async (peerPubkey: string) => { + if (!currentUser.value || !isConnected.value) { + throw new Error('Not connected') + } + + const myPubkey = currentUser.value.pubkey + + // Subscribe to direct messages (kind 4) + connectedRelays.value.forEach(relay => { + const sub = relay.sub([ + { + kinds: [4], + authors: [peerPubkey], + '#p': [myPubkey] + }, + { + kinds: [4], + authors: [myPubkey], + '#p': [peerPubkey] + } + ]) + + sub.on('event', (event: any) => { + handleIncomingMessage(event, peerPubkey) + }) + }) + } + + // Handle incoming message + const handleIncomingMessage = async (event: any, peerPubkey: string) => { + if (processedMessageIds.value.has(event.id)) { + return + } + + processedMessageIds.value.add(event.id) + + try { + // Decrypt the message + const decryptedContent = await window.NostrTools.nip04.decrypt( + currentUser.value!.prvkey, + event.pubkey, + event.content + ) + + const message: ChatMessage = { + id: event.id, + content: decryptedContent, + created_at: event.created_at, + sent: event.pubkey === currentUser.value!.pubkey, + pubkey: event.pubkey + } + + // Add message to the appropriate conversation + const conversationKey = event.pubkey === currentUser.value!.pubkey + ? peerPubkey + : event.pubkey + + if (!messages.value.has(conversationKey)) { + messages.value.set(conversationKey, []) + } + + messages.value.get(conversationKey)!.push(message) + + // Sort messages by timestamp + messages.value.get(conversationKey)!.sort((a, b) => a.created_at - b.created_at) + + } catch (error) { + console.error('Failed to decrypt message:', error) + } + } + + // Send message to a peer + const sendMessage = async (peerPubkey: string, content: string) => { + if (!currentUser.value || !isConnected.value) { + throw new Error('Not connected') + } + + try { + // Encrypt the message + const encryptedContent = await window.NostrTools.nip04.encrypt( + currentUser.value.prvkey, + peerPubkey, + content + ) + + // Create the event + const event = { + kind: 4, + pubkey: currentUser.value.pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', peerPubkey]], + content: encryptedContent + } + + // Sign the event + event.id = window.NostrTools.getEventHash(event) + event.sig = window.NostrTools.getSignature(event, currentUser.value.prvkey) + + // Publish to relays + const publishPromises = connectedRelays.value.map(relay => { + return new Promise((resolve, reject) => { + const pub = relay.publish(event) + + pub.on('ok', () => { + console.log('Message published successfully') + resolve() + }) + + pub.on('failed', (reason: string) => { + console.error('Failed to publish message:', reason) + reject(new Error(reason)) + }) + }) + }) + + await Promise.all(publishPromises) + + // Add message to local state + const message: ChatMessage = { + id: event.id, + content, + created_at: event.created_at, + sent: true, + pubkey: currentUser.value.pubkey + } + + if (!messages.value.has(peerPubkey)) { + messages.value.set(peerPubkey, []) + } + + messages.value.get(peerPubkey)!.push(message) + + // Sort messages by timestamp + messages.value.get(peerPubkey)!.sort((a, b) => a.created_at - b.created_at) + + } catch (error) { + console.error('Failed to send message:', error) + throw error + } + } + + // Get messages for a specific peer + const getMessages = (peerPubkey: string): ChatMessage[] => { + return messages.value.get(peerPubkey) || [] + } + + // Clear messages for a specific peer + const clearMessages = (peerPubkey: string) => { + messages.value.delete(peerPubkey) + } + + return { + // State + isConnected: readonly(isConnected), + messages: readonly(messages), + isLoggedIn: readonly(isLoggedIn), + + // Methods + connect, + disconnect, + subscribeToPeer, + sendMessage, + getMessages, + clearMessages, + loadCurrentUser + } +} \ No newline at end of file diff --git a/src/pages/ChatPage.vue b/src/pages/ChatPage.vue new file mode 100644 index 0000000..81222e2 --- /dev/null +++ b/src/pages/ChatPage.vue @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 559aa14..1f0674c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -49,6 +49,15 @@ const router = createRouter({ requiresAuth: true } }, + { + path: '/chat', + name: 'chat', + component: () => import('@/pages/ChatPage.vue'), + meta: { + title: 'Nostr Chat', + requiresAuth: true + } + }, ] })