import { nostrService } from './nostr' import type { AlbyInvoice } from '@/types/alby' import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage' import { buildPresentationEvent, sendEncryptedContent } from './articlePublisherHelpers' import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes' import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation' import { buildFailure, encryptAndPublish } from './articlePublisherPublish' import { writeOrchestrator } from './writeOrchestrator' import { finalizeEvent } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' import { generateAuthorHashId } from './hashIdGenerator' import { buildObjectId } from './urlGenerator' import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing' export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes' /** * 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 async validatePublishRequest( draft: ArticleDraft, authorPubkey: string, authorPrivateKey?: string ): Promise { const keySetup = prepareAuthorKeys(authorPubkey, authorPrivateKey) if (!keySetup.success) { return { success: false, error: keySetup.error ?? 'Key setup failed' } } const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey() if (!authorPrivateKeyForEncryption) { return { success: false, error: 'Private key required for encryption' } } const presentation = await this.getAuthorPresentation(authorPubkey) if (!presentation) { return { success: false, error: 'Vous devez créer un article de présentation avant de publier des articles.' } } if (!isValidCategory(draft.category)) { return { success: false, error: 'Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).' } } const expectedAmount = 800 if (draft.zapAmount !== expectedAmount) { return { success: false, error: `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`, } } return { success: true, authorPrivateKeyForEncryption, category: draft.category } } /** * 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 validation = await this.validatePublishRequest(draft, authorPubkey, authorPrivateKey) if (!validation.success) { const { error } = validation return buildFailure(error) } const presentation = await this.getAuthorPresentation(authorPubkey) if (!presentation) { return buildFailure('Presentation not found') } return encryptAndPublish({ draft, authorPubkey, authorPrivateKeyForEncryption: validation.authorPrivateKeyForEncryption, category: validation.category, presentationId: presentation.id, }) } catch (error) { console.error('Error publishing article:', error) return 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 */ private logSendResult(result: import('./articlePublisherHelpers').SendContentResult, articleId: string, recipientPubkey: string): void { if (result.success) { console.warn('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(), }) } } 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) this.logSendResult(result, articleId, recipientPubkey) 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 { return publishPresentationArticleCore({ draft, authorPubkey, authorPrivateKey }) } catch (error) { console.error('Error publishing presentation article:', error) return buildFailure(error instanceof Error ? error.message : 'Unknown error') } } async getAuthorPresentation(pubkey: string): Promise { try { // Read only from IndexedDB cache const { objectCache: objectCacheService } = await import('./objectCache') const cached = await objectCacheService.getAuthorByPubkey(pubkey) if (cached) { const presentation = cached // Calculate totalSponsoring from cache const { getAuthorSponsoring } = await import('./sponsoring') presentation.totalSponsoring = await getAuthorSponsoring(presentation.pubkey) return presentation } // Not found in cache - return null (no network request) return null } catch (error) { console.error('Error getting author presentation:', error) return null } } } export const articlePublisher = new ArticlePublisher() async function publishPresentationArticleCore(params: { draft: AuthorPresentationDraft authorPubkey: string authorPrivateKey: string }): Promise { nostrService.setPublicKey(params.authorPubkey) nostrService.setPrivateKey(params.authorPrivateKey) const authorName = extractAuthorNameFromTitle(params.draft.title) const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft) const category = 'sciencefiction' const version = 0 const index = 0 const hashId = await generateAuthorHashId({ pubkey: params.authorPubkey, authorName, presentation, contentDescription, mainnetAddress: params.draft.mainnetAddress ?? undefined, pictureUrl: params.draft.pictureUrl ?? undefined, category, }) const hash = hashId const id = buildObjectId(hash, index, version) const parsedAuthor = buildParsedAuthorPresentation({ draft: params.draft, authorPubkey: params.authorPubkey, id, hash, version, index, presentation, contentDescription, }) const eventTemplate = await buildPresentationEvent({ draft: params.draft, authorPubkey: params.authorPubkey, authorName, category, version, index, }) writeOrchestrator.setPrivateKey(params.authorPrivateKey) const secretKey = hexToBytes(params.authorPrivateKey) const event = finalizeEvent(eventTemplate, secretKey) const relays = await getActiveRelaysOrPrimary() const result = await writeOrchestrator.writeAndPublish( { objectType: 'author', hash, event, parsed: parsedAuthor, version, hidden: false, index, }, relays ) if (!result.success) { return buildFailure('Failed to publish presentation article') } return { articleId: event.id, previewEventId: event.id, success: true } } function buildParsedAuthorPresentation(params: { draft: AuthorPresentationDraft authorPubkey: string id: string hash: string version: number index: number presentation: string contentDescription: string }): import('@/types/nostr').AuthorPresentationArticle { return { id: params.id, hash: params.hash, version: params.version, index: params.index, pubkey: params.authorPubkey, title: params.draft.title, preview: params.draft.preview, content: params.draft.content, description: params.presentation, contentDescription: params.contentDescription, thumbnailUrl: params.draft.pictureUrl ?? '', createdAt: Math.floor(Date.now() / 1000), zapAmount: 0, paid: true, category: 'author-presentation', isPresentation: true, mainnetAddress: params.draft.mainnetAddress ?? '', totalSponsoring: 0, originalCategory: 'science-fiction', ...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}), } } async function getActiveRelaysOrPrimary(): Promise { const { relaySessionManager } = await import('./relaySessionManager') const activeRelays = await relaySessionManager.getActiveRelays() if (activeRelays.length > 0) { return activeRelays } const { getPrimaryRelay } = await import('./config') return [await getPrimaryRelay()] }