story-research-zapwall/lib/articlePublisher.ts
2026-01-10 09:41:57 +01:00

329 lines
11 KiB
TypeScript

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<PublishValidationResult> {
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<PublishedArticle> {
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<AlbyInvoice | null> {
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<import('./articlePublisherHelpers').SendContentResult> {
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<void> {
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<PublishedArticle> {
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<import('@/types/nostr').AuthorPresentationArticle | null> {
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<PublishedArticle> {
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<string[]> {
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length > 0) {
return activeRelays
}
const { getPrimaryRelay } = await import('./config')
return [await getPrimaryRelay()]
}