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

156
CHAT_INTEGRATION.md Normal file
View file

@ -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 <admin_token>
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 <admin_token>
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

View file

@ -0,0 +1,291 @@
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold">Chat</h2>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
Refresh Peers
</Button>
</div>
<!-- Main Content -->
<div class="flex flex-1 overflow-hidden">
<!-- Peer List -->
<div class="w-80 border-r bg-muted/30">
<div class="p-4 border-b">
<h3 class="font-medium">Peers ({{ peers.length }})</h3>
</div>
<ScrollArea class="h-full">
<div class="p-2 space-y-1">
<div
v-for="peer in peers"
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
]"
>
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ peer.username || 'Unknown User' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
</div>
</div>
</ScrollArea>
</div>
<!-- Chat Area -->
<div class="flex-1 flex flex-col">
<div v-if="selectedPeer" class="flex-1 flex flex-col">
<!-- Chat Header -->
<div class="p-4 border-b">
<div class="flex items-center space-x-3">
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
<AvatarFallback>{{ getPeerInitials(selectedPeer) }}</AvatarFallback>
</Avatar>
<div>
<h3 class="font-medium">{{ selectedPeer.username || 'Unknown User' }}</h3>
<p class="text-sm text-muted-foreground">
{{ formatPubkey(selectedPeer.pubkey) }}
</p>
</div>
</div>
</div>
<!-- Messages -->
<ScrollArea class="flex-1 p-4">
<div class="space-y-4">
<div
v-for="message in currentMessages"
:key="message.id"
:class="[
'flex',
message.sent ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
message.sent
? 'bg-primary text-primary-foreground'
: 'bg-muted'
]"
>
<p class="text-sm">{{ message.content }}</p>
<p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }}
</p>
</div>
</div>
</div>
<div ref="messagesEndRef" />
</ScrollArea>
<!-- Message Input -->
<div class="p-4 border-t">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<Input
v-model="messageInput"
placeholder="Type a message..."
:disabled="!isConnected || !selectedPeer"
class="flex-1"
/>
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
<Send class="h-4 w-4" />
</Button>
</form>
</div>
</div>
<!-- No Peer Selected -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<MessageSquare class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">No peer selected</h3>
<p class="text-muted-foreground">
Select a peer from the list to start chatting
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Send, RefreshCw, MessageSquare } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useNostrChat } from '@/composables/useNostrChat'
// Types
interface Peer {
user_id: string
username: string
pubkey: string
}
interface ChatMessage {
id: string
content: string
created_at: number
sent: boolean
}
// State
const peers = ref<Peer[]>([])
const selectedPeer = ref<Peer | null>(null)
const messageInput = ref('')
const messagesEndRef = ref<HTMLDivElement | null>(null)
const isLoading = ref(false)
// Nostr chat composable
const {
isConnected,
messages,
sendMessage: sendNostrMessage,
connect,
disconnect,
subscribeToPeer
} = useNostrChat()
// Computed
const currentMessages = computed(() => {
if (!selectedPeer.value) return []
return messages.value.get(selectedPeer.value.pubkey) || []
})
// Methods
const loadPeers = async () => {
try {
isLoading.value = true
const response = await fetch('/users/api/v1/nostr/pubkeys', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
})
if (!response.ok) {
throw new Error('Failed to load peers')
}
const data = await response.json()
peers.value = data.map((peer: any) => ({
user_id: peer.user_id,
username: peer.username,
pubkey: peer.pubkey
}))
console.log(`Loaded ${peers.value.length} peers`)
} catch (error) {
console.error('Failed to load peers:', error)
} finally {
isLoading.value = false
}
}
const refreshPeers = () => {
loadPeers()
}
const selectPeer = async (peer: Peer) => {
selectedPeer.value = peer
messageInput.value = ''
// Subscribe to messages from this peer
await subscribeToPeer(peer.pubkey)
// Scroll to bottom after messages load
nextTick(() => {
scrollToBottom()
})
}
const sendMessage = async () => {
if (!selectedPeer.value || !messageInput.value.trim()) return
try {
await sendNostrMessage(selectedPeer.value.pubkey, messageInput.value)
messageInput.value = ''
// Scroll to bottom
nextTick(() => {
scrollToBottom()
})
} catch (error) {
console.error('Failed to send message:', error)
}
}
const scrollToBottom = () => {
if (messagesEndRef.value) {
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
}
}
const formatPubkey = (pubkey: string) => {
return pubkey.slice(0, 8) + '...' + pubkey.slice(-8)
}
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const getPeerAvatar = (peer: Peer) => {
// You can implement avatar logic here
return null
}
const getPeerInitials = (peer: Peer) => {
if (peer.username) {
return peer.username.slice(0, 2).toUpperCase()
}
return peer.pubkey.slice(0, 2).toUpperCase()
}
// Lifecycle
onMounted(async () => {
await connect()
await loadPeers()
})
onUnmounted(() => {
disconnect()
})
// Watch for new messages and scroll to bottom
watch(currentMessages, () => {
nextTick(() => {
scrollToBottom()
})
})
</script>

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
}
}

22
src/pages/ChatPage.vue Normal file
View file

@ -0,0 +1,22 @@
<template>
<div class="container mx-auto p-4 h-screen">
<div class="flex flex-col h-full">
<!-- Page Header -->
<div class="mb-4">
<h1 class="text-2xl font-bold">Nostr Chat</h1>
<p class="text-muted-foreground">
Chat with other LNBits users using Nostr relays
</p>
</div>
<!-- Chat Component -->
<div class="flex-1 border rounded-lg overflow-hidden">
<ChatComponent />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ChatComponent from '@/components/nostr/ChatComponent.vue'
</script>

View file

@ -49,6 +49,15 @@ const router = createRouter({
requiresAuth: true
}
},
{
path: '/chat',
name: 'chat',
component: () => import('@/pages/ChatPage.vue'),
meta: {
title: 'Nostr Chat',
requiresAuth: true
}
},
]
})