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:
parent
f4c3f3a0a3
commit
0b62418310
5 changed files with 779 additions and 0 deletions
301
src/composables/useNostrChat.ts
Normal file
301
src/composables/useNostrChat.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue