import { ref, computed, readonly } from 'vue' import { SimplePool, nip04, finalizeEvent, type EventTemplate } from 'nostr-tools' import { hexToBytes } from '@/lib/utils/crypto' import { getAuthToken } from '@/lib/config/lnbits' import { config } from '@/lib/config' // Types export interface ChatMessage { id: string content: string created_at: number sent: boolean pubkey: string } export interface NostrRelayConfig { url: string read?: boolean write?: boolean } // Get relays from config - requires VITE_NOSTR_RELAYS to be set const getRelays = (): NostrRelayConfig[] => { const configuredRelays = config.nostr.relays if (!configuredRelays || configuredRelays.length === 0) { throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality') } return configuredRelays.map((url: string) => ({ url, read: true, write: true })) } export function useNostrChat() { // State const isConnected = ref(false) const messages = ref>(new Map()) const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null) const pool = ref(null) const processedMessageIds = ref(new Set()) // Callback for when messages change const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null) // Computed const isLoggedIn = computed(() => !!currentUser.value) // Initialize Nostr pool const initializePool = () => { if (!pool.value) { pool.value = new SimplePool() } } // Connect to relays const connectToRelay = async (url: string): Promise => { try { initializePool() const relay = pool.value!.ensureRelay(url) 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 { // Get current user from LNBits await loadCurrentUser() if (!currentUser.value) { console.warn('No user logged in - chat functionality will be limited') // Don't throw error, just continue without user data // The chat will still work for viewing messages, but sending will fail } // Initialize pool initializePool() // Connect to relays const relayConfigs = getRelays() const relays = await Promise.all( relayConfigs.map(relay => connectToRelay(relay.url)) ) const connectedRelays = relays.filter(relay => relay !== null) isConnected.value = connectedRelays.length > 0 console.log(`Connected to ${connectedRelays.length} relays`) } catch (error) { console.error('Failed to connect:', error) // Don't throw error, just log it and continue // This allows the chat to still work for viewing messages } } // Disconnect from relays const disconnect = () => { if (pool.value) { const relayConfigs = getRelays() pool.value.close(relayConfigs.map(r => r.url)) pool.value = null } isConnected.value = false messages.value.clear() processedMessageIds.value.clear() } // Load current user from LNBits const loadCurrentUser = async () => { try { // Get current user from LNBits API using the auth endpoint const authToken = getAuthToken() if (!authToken) { throw new Error('No authentication token found') } const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/me`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }) console.log('API Response status:', response.status) console.log('API Response headers:', response.headers) const responseText = await response.text() console.log('API Response text:', responseText) if (response.ok) { try { const user = JSON.parse(responseText) currentUser.value = { pubkey: user.pubkey, prvkey: user.prvkey } } catch (parseError) { console.error('JSON Parse Error:', parseError) console.error('Response was:', responseText) throw new Error('Invalid JSON response from API') } } else { console.error('API Error:', response.status, responseText) throw new Error(`Failed to load current user: ${response.status}`) } } 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) { console.warn('No user logged in - cannot subscribe to peer messages') return null } // Check if we have a pool and are connected if (!pool.value) { console.warn('No pool available - initializing...') initializePool() } if (!isConnected.value) { console.warn('Not connected to relays - attempting to connect...') await connect() } if (!pool.value) { throw new Error('Failed to initialize Nostr pool') } const myPubkey = currentUser.value.pubkey // First, load historical messages await loadHistoricalMessages(peerPubkey, myPubkey) // Then subscribe to new messages const relayConfigs = getRelays() console.log('Subscribing to new messages for peer:', peerPubkey, 'with filters:', [ { kinds: [4], authors: [peerPubkey], '#p': [myPubkey] }, { kinds: [4], authors: [myPubkey], '#p': [peerPubkey] } ]) const sub = pool.value.subscribeMany( relayConfigs.map(r => r.url), [ { kinds: [4], authors: [peerPubkey], '#p': [myPubkey] }, { kinds: [4], authors: [myPubkey], '#p': [peerPubkey] } ], { onevent(event) { console.log('Received live event:', event.id, 'author:', event.pubkey) handleIncomingMessage(event, peerPubkey) } } ) return sub } // Load historical messages for a peer const loadHistoricalMessages = async (peerPubkey: string, myPubkey: string) => { console.log('Loading historical messages for peer:', peerPubkey) console.log('My pubkey:', myPubkey) const relayConfigs = getRelays() console.log('Using relays:', relayConfigs.map(r => r.url)) const filters = [ { kinds: [4], authors: [peerPubkey], '#p': [myPubkey] }, { kinds: [4], authors: [myPubkey], '#p': [peerPubkey] } ] console.log('Historical query filters:', filters) const historicalSub = pool.value!.subscribeMany( relayConfigs.map(r => r.url), filters, { onevent(event) { console.log('Received historical event:', { id: event.id, author: event.pubkey, isSentByMe: event.pubkey === myPubkey, contentLength: event.content.length }) handleIncomingMessage(event, peerPubkey) }, oneose() { console.log('Historical query completed for peer:', peerPubkey) } } ) // Wait a bit for historical messages to load await new Promise(resolve => setTimeout(resolve, 3000)) historicalSub.close() console.log('Historical query closed for peer:', peerPubkey) } // Handle incoming message const handleIncomingMessage = async (event: any, peerPubkey: string) => { if (processedMessageIds.value.has(event.id)) { return } processedMessageIds.value.add(event.id) console.log('Handling incoming message:', { eventId: event.id, eventPubkey: event.pubkey, myPubkey: currentUser.value!.pubkey, peerPubkey, isSentByMe: event.pubkey === currentUser.value!.pubkey }) try { // Decrypt the message // For NIP-04 direct messages, always use peerPubkey as the second argument // This is the public key of the other party in the conversation const isSentByMe = event.pubkey === currentUser.value!.pubkey console.log('Decrypting message:', { eventId: event.id, isSentByMe, eventPubkey: event.pubkey, myPubkey: currentUser.value!.pubkey, peerPubkey, contentLength: event.content.length }) const decryptedContent = await nip04.decrypt( currentUser.value!.prvkey, peerPubkey, // Always use peerPubkey for shared secret derivation event.content ) console.log('Successfully decrypted message:', { eventId: event.id, contentLength: decryptedContent.length, contentPreview: decryptedContent.substring(0, 50) + '...' }) 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 // Always use peerPubkey as the conversation key for both sent and received messages const conversationKey = peerPubkey console.log('Storing message with conversation key:', conversationKey) if (!messages.value.has(conversationKey)) { messages.value.set(conversationKey, []) } 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) // Force reactivity by triggering a change messages.value = new Map(messages.value) // Trigger callback if set if (onMessageAdded.value) { onMessageAdded.value(conversationKey) } console.log('Messages for conversation:', messages.value.get(conversationKey)?.map(m => ({ id: m.id, sent: m.sent, content: m.content.substring(0, 30) + '...', timestamp: m.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) { throw new Error('No user logged in - please authenticate first') } // Check if we have a pool and are connected if (!pool.value) { console.warn('No pool available - initializing...') initializePool() } if (!isConnected.value) { console.warn('Not connected to relays - attempting to connect...') await connect() } if (!pool.value) { throw new Error('Failed to initialize Nostr pool') } try { // Validate keys before encryption if (!currentUser.value.prvkey || !peerPubkey) { throw new Error('Missing private key or peer public key') } // Ensure keys are in correct hex format (64 characters for private key, 64 characters for public key) const privateKey = currentUser.value.prvkey.startsWith('0x') ? currentUser.value.prvkey.slice(2) : currentUser.value.prvkey const publicKey = peerPubkey.startsWith('0x') ? peerPubkey.slice(2) : peerPubkey if (privateKey.length !== 64) { throw new Error(`Invalid private key length: ${privateKey.length} (expected 64)`) } if (publicKey.length !== 64) { throw new Error(`Invalid public key length: ${publicKey.length} (expected 64)`) } // Validate hex format const hexRegex = /^[0-9a-fA-F]+$/ if (!hexRegex.test(privateKey)) { throw new Error(`Invalid private key format: contains non-hex characters`) } if (!hexRegex.test(publicKey)) { throw new Error(`Invalid public key format: contains non-hex characters`) } // Encrypt the message let encryptedContent: string try { encryptedContent = await nip04.encrypt( privateKey, publicKey, content ) } catch (encryptError) { console.error('Encryption failed:', encryptError) throw new Error(`Encryption failed: ${encryptError instanceof Error ? encryptError.message : String(encryptError)}`) } // Create the event template const eventTemplate: EventTemplate = { kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', peerPubkey]], content: encryptedContent } // Finalize the event (sign it) const event = finalizeEvent(eventTemplate, hexToBytes(privateKey)) // Publish to relays const relayConfigs = getRelays() const publishPromises = relayConfigs.map(relay => { return pool.value!.publish([relay.url], event) }) 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 } // Add to processed IDs to prevent duplicate processing processedMessageIds.value.add(event.id) 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) // Force reactivity by triggering a change messages.value = new Map(messages.value) // Trigger callback if set if (onMessageAdded.value) { onMessageAdded.value(peerPubkey) } } 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, onMessageAdded } }