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.
This commit is contained in:
padreug 2025-08-05 20:34:04 +02:00
parent f4c3f3a0a3
commit 0b62418310
5 changed files with 779 additions and 0 deletions

View file

@ -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<Map<string, ChatMessage[]>>(new Map())
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
const connectedRelays = ref<any[]>([])
const processedMessageIds = ref(new Set<string>())
// Computed
const isLoggedIn = computed(() => !!currentUser.value)
// Initialize NostrTools
const waitForNostrTools = async (timeout = 5000): Promise<void> => {
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<any> => {
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<void>((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
}
}