import { nostrService } from './nostr' import type { AlbyInvoice } from '@/types/alby' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { MediaRef } from '@/types/nostr' import { storePrivateContent, getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent, } from './articleStorage' import { createArticleInvoice, createPreviewEvent } from './articleInvoice' import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers' import { encryptArticleContent, encryptDecryptionKey, } from './articleEncryption' export interface ArticleDraft { title: string preview: string content: string // Full content that will be sent as private message after payment zapAmount: number category?: 'science-fiction' | 'scientific-research' seriesId?: string bannerUrl?: string media?: MediaRef[] } export interface AuthorPresentationDraft { title: string preview: string content: string presentation: string contentDescription: string mainnetAddress: string pictureUrl?: string | undefined } export interface PublishedArticle { articleId: string previewEventId: string invoice?: AlbyInvoice // Invoice created by author (required if success) success: boolean error?: string } /** * Service for publishing articles on Nostr * Handles publishing preview (public note), creating invoice, and storing full content for later private message */ export class ArticlePublisher { // Removed unused siteTag - using new tag system instead private buildFailure(error?: string): PublishedArticle { const base: PublishedArticle = { articleId: '', previewEventId: '', success: false, } return error ? { ...base, error } : base } private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } { nostrService.setPublicKey(authorPubkey) if (authorPrivateKey) { nostrService.setPrivateKey(authorPrivateKey) return { success: true } } const existingPrivateKey = nostrService.getPrivateKey() if (!existingPrivateKey) { return { success: false, error: 'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.', } } return { success: true } } private isValidCategory(category?: ArticleDraft['category']): category is NonNullable { return category === 'science-fiction' || category === 'scientific-research' } private async publishPreview( draft: ArticleDraft, invoice: AlbyInvoice, presentationId: string, extraTags?: string[][], encryptedContent?: string, encryptedKey?: string ): Promise { const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey) const publishedEvent = await nostrService.publishEvent(previewEvent) return publishedEvent ?? null } private buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable): string[][] { // Media tags are still supported in the new system const extraTags: string[][] = [] if (draft.media && draft.media.length > 0) { draft.media.forEach((m) => { extraTags.push(['media', m.url, m.type]) }) } return extraTags } /** * Publish an article with encrypted content as a public note (kind:1) * Creates a Lightning invoice for the article * The content is encrypted and published, and the decryption key is sent via private message after payment */ async publishArticle( draft: ArticleDraft, authorPubkey: string, authorPrivateKey?: string ): Promise { try { const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey) if (!keySetup.success) { return this.buildFailure(keySetup.error) } const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey() if (!authorPrivateKeyForEncryption) { return this.buildFailure('Private key required for encryption') } const presentation = await this.getAuthorPresentation(authorPubkey) if (!presentation) { return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.') } if (!this.isValidCategory(draft.category)) { return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).') } const category = draft.category // Verify zap amount matches expected commission structure const expectedAmount = 800 // PLATFORM_COMMISSIONS.article.total if (draft.zapAmount !== expectedAmount) { return this.buildFailure( `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)` ) } // Encrypt the article content const { encryptedContent, key, iv } = await encryptArticleContent(draft.content) // Encrypt the decryption key with the author's public key (for storage in tags) const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey) const invoice = await createArticleInvoice(draft) const extraTags = this.buildArticleExtraTags(draft, category) const publishedEvent = await this.publishPreview( draft, invoice, presentation.id, extraTags, encryptedContent, encryptedKey ) if (!publishedEvent) { return this.buildFailure('Failed to publish article') } // Store the decryption key locally for sending after payment await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv) console.log('Article published with encrypted content', { articleId: publishedEvent.id, authorPubkey, timestamp: new Date().toISOString(), }) return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true } } catch (error) { console.error('Error publishing article:', error) return this.buildFailure(error instanceof Error ? error.message : 'Unknown error') } } /** * Update an existing article by publishing a new event that references the original */ /** * Get stored private content for an article */ getStoredPrivateContent(articleId: string): Promise<{ content: string authorPubkey: string invoice?: AlbyInvoice } | null> { return getStoredPrivateContent(articleId) } /** * Get stored invoice for an article */ getStoredInvoice(articleId: string): Promise { return getStoredInvoice(articleId) } /** * Send private content to a user after payment confirmation * Returns detailed result with message event ID and verification status */ async sendPrivateContent( articleId: string, recipientPubkey: string, authorPrivateKey: string ): Promise { try { const stored = await getStoredPrivateContent(articleId) if (!stored) { const error = 'Private content not found for article' console.error(error, { articleId, recipientPubkey }) return { success: false, error, } } const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey) if (result.success) { console.log('Private content sent successfully', { articleId, recipientPubkey, messageEventId: result.messageEventId, verified: result.verified, timestamp: new Date().toISOString(), }) } else { console.error('Failed to send private content', { articleId, recipientPubkey, error: result.error, timestamp: new Date().toISOString(), }) } return result } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error('Error sending private content', { articleId, recipientPubkey, error: errorMessage, timestamp: new Date().toISOString(), }) return { success: false, error: errorMessage, } } } /** * Remove stored private content (after successful send or expiry) */ async removeStoredPrivateContent(articleId: string): Promise { await removeStoredPrivateContent(articleId) } /** * Publish an author presentation article (obligatory for all authors) * This article is free and contains the author's presentation, content description, and mainnet address */ async publishPresentationArticle( draft: AuthorPresentationDraft, authorPubkey: string, authorPrivateKey: string ): Promise { try { nostrService.setPublicKey(authorPubkey) nostrService.setPrivateKey(authorPrivateKey) // Generate event ID before building event (using a temporary ID that will be replaced by Nostr) const tempEventId = 'temp_' + Math.random().toString(36).substring(7) const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction')) if (!publishedEvent) { return this.buildFailure('Failed to publish presentation article') } return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, success: true, } } catch (error) { console.error('Error publishing presentation article:', error) return this.buildFailure(error instanceof Error ? error.message : 'Unknown error') } } async getAuthorPresentation(pubkey: string): Promise { try { const pool = nostrService.getPool() if (!pool) { return null } return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey) } catch (error) { console.error('Error getting author presentation:', error) return null } } } export const articlePublisher = new ArticlePublisher()