import { Event, EventTemplate, finalizeEvent, nip19, SimplePool } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' import type { Article, NostrProfile } from '@/types/nostr' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import { parseArticleFromEvent } from './nostrEventParsing' import { getPrivateContent as getPrivateContentFromPool, getDecryptionKey, decryptArticleContentWithKey, } from './nostrPrivateMessages' import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification' import { subscribeWithTimeout } from './nostrSubscription' import { getPrimaryRelay, getPrimaryRelaySync } from './config' import { buildTagFilter } from './nostrTagSystem' class NostrService { private pool: SimplePool | null = null private privateKey: string | null = null private publicKey: string | null = null constructor() { if (typeof window !== 'undefined') { this.initializePool() } } private initializePool() { this.pool = new SimplePool() } setPrivateKey(privateKey: string) { this.privateKey = privateKey try { const decoded = nip19.decode(privateKey) if (decoded.type === 'nsec' && typeof decoded.data === 'string') { this.privateKey = decoded.data } } catch (_e) { // Assume it's already a hex string } } getPrivateKey(): string | null { return this.privateKey } getPublicKey(): string | null { return this.publicKey } setPublicKey(publicKey: string) { this.publicKey = publicKey try { const decoded = nip19.decode(publicKey) if (decoded.type === 'npub' && typeof decoded.data === 'string') { this.publicKey = decoded.data } } catch (_e) { // Assume it's already a hex string } } async publishEvent(eventTemplate: EventTemplate): Promise { if (!this.privateKey || !this.pool) { throw new Error('Private key not set or pool not initialized') } const unsignedEvent: EventTemplate = { ...eventTemplate, created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000), } const secretKey = hexToBytes(this.privateKey) const event = finalizeEvent(unsignedEvent, secretKey) try { const relayUrl = await getPrimaryRelay() const pubs = this.pool.publish([relayUrl], event) await Promise.all(pubs) return event } catch (e) { throw new Error(`Publish failed: ${e}`) } } private createArticleSubscription(pool: SimplePoolWithSub, limit: number) { const filters = [ { ...buildTagFilter({ type: 'publication', }), limit, }, ] const relayUrl = getPrimaryRelaySync() return pool.sub([relayUrl], filters) } subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void { if (typeof window === 'undefined') { throw new Error('Cannot subscribe on server side') } if (!this.pool) { this.initializePool() } if (!this.pool) { throw new Error('Pool not initialized') } const pool = this.pool as SimplePoolWithSub const sub = this.createArticleSubscription(pool, limit) sub.on('event', (event: Event) => { try { const article = parseArticleFromEvent(event) if (article) { callback(article) } } catch (e) { console.error('Error parsing article:', e) } }) return () => { sub.unsub() } } getArticleById(eventId: string): Promise
{ if (!this.pool) { throw new Error('Pool not initialized') } const filters = [{ ids: [eventId], kinds: [1] }] return subscribeWithTimeout(this.pool, filters, parseArticleFromEvent, 5000) } getPrivateContent(eventId: string, authorPubkey: string): Promise { if (!this.privateKey || !this.pool || !this.publicKey) { throw new Error('Private key not set or pool not initialized') } return getPrivateContentFromPool(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey) } /** * Get and decrypt article content using decryption key from private message * First retrieves the article event to get the encrypted content, * then retrieves the decryption key from private messages, * and finally decrypts the content */ private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> { if (!this.privateKey || !this.pool || !this.publicKey) { return null } return await getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey) } async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise { if (!this.privateKey || !this.pool || !this.publicKey) { throw new Error('Private key not set or pool not initialized') } try { const event = await this.getEventById(eventId) if (!event) { console.error('Event not found', { eventId, authorPubkey }) return null } const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey) if (!decryptionKey) { console.warn('Decryption key not found in private messages', { eventId, authorPubkey }) return null } return await decryptArticleContentWithKey(event.content, decryptionKey) } catch (error) { console.error('Error decrypting article content', { eventId, authorPubkey, error: error instanceof Error ? error.message : 'Unknown error', }) return null } } /** * Get event by ID (helper method) */ private async getEventById(eventId: string): Promise { if (!this.pool) { throw new Error('Pool not initialized') } const filters = [{ ids: [eventId], kinds: [1] }] return subscribeWithTimeout(this.pool, filters, (event: Event) => event, 5000) } getProfile(pubkey: string): Promise { if (!this.pool) { throw new Error('Pool not initialized') } const filters = [ { kinds: [0], authors: [pubkey], limit: 1, }, ] const parseProfile = (event: Event) => { try { const profile = JSON.parse(event.content) as NostrProfile return { ...profile, pubkey } } catch (error) { console.error('Error parsing profile:', error) return null } } return subscribeWithTimeout(this.pool, filters, parseProfile, 5000) } /** * Update Nostr profile (kind 0) with new metadata * Merges new fields with existing profile data */ async updateProfile(updates: Partial): Promise { if (!this.privateKey || !this.publicKey) { throw new Error('Private key and public key must be set to update profile') } // Get existing profile to merge with updates const existingProfile = await this.getProfile(this.publicKey) const currentProfile: NostrProfile = existingProfile ?? { pubkey: this.publicKey, } // Merge updates with existing profile const updatedProfile: NostrProfile = { ...currentProfile, ...updates, pubkey: this.publicKey, // Always use current pubkey } // Create kind 0 event const profileEvent: EventTemplate = { kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify({ name: updatedProfile.name, about: updatedProfile.about, picture: updatedProfile.picture, nip05: updatedProfile.nip05, lud16: updatedProfile.lud16, lud06: updatedProfile.lud06, }), } await this.publishEvent(profileEvent) } async createZapRequest(targetPubkey: string, targetEventId: string, amount: number): Promise { if (!this.privateKey) { throw new Error('Private key not set') } const relayUrl = await getPrimaryRelay() const zapRequest: EventTemplate = { kind: 9734, // Zap request created_at: Math.floor(Date.now() / 1000), tags: [ ['p', targetPubkey], ['e', targetEventId], ['amount', amount.toString()], ['relays', relayUrl], ], content: '', } const event = await this.publishEvent(zapRequest) if (!event) { throw new Error('Failed to create zap request') } return event } // Check if user has paid for an article by looking for zap receipts checkZapReceipt( targetPubkey: string, targetEventId: string, amount: number, userPubkey?: string ): Promise { if (!this.publicKey || !this.pool) { return Promise.resolve(false) } // Use provided userPubkey or fall back to current public key const checkPubkey = userPubkey ?? this.publicKey return checkZapReceiptHelper(this.pool, targetPubkey, targetEventId, amount, checkPubkey) } /** * Get the pool instance (for use by other services) */ getPool(): SimplePool | null { return this.pool } } export const nostrService = new NostrService()