240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
import { nostrService } from './nostr'
|
|
import type { AlbyInvoice } from '@/types/alby'
|
|
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
|
|
import { 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, type Event, type EventTemplate } from 'nostr-tools'
|
|
import { hexToBytes } from 'nostr-tools/utils'
|
|
import { buildPresentationPublishContext, getActiveRelaysOrPrimary } from './articlePublisherPresentationHelpers'
|
|
|
|
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> {
|
|
initializeAuthorKeys({ authorPubkey: params.authorPubkey, authorPrivateKey: params.authorPrivateKey })
|
|
const ctx = await buildPresentationPublishContext({ draft: params.draft, authorPubkey: params.authorPubkey })
|
|
const event = finalizeEventFromTemplate({ eventTemplate: ctx.eventTemplate, authorPrivateKey: params.authorPrivateKey })
|
|
writeOrchestrator.setPrivateKey(params.authorPrivateKey)
|
|
|
|
const relays = await getActiveRelaysOrPrimary()
|
|
const result = await writeOrchestrator.writeAndPublish(
|
|
{ objectType: 'author', hash: ctx.hash, event, parsed: ctx.parsedAuthor, version: ctx.version, hidden: false, index: ctx.index },
|
|
relays
|
|
)
|
|
return result.success ? { articleId: event.id, previewEventId: event.id, success: true } : buildFailure('Failed to publish presentation article')
|
|
}
|
|
|
|
function initializeAuthorKeys(params: { authorPubkey: string; authorPrivateKey: string }): void {
|
|
nostrService.setPublicKey(params.authorPubkey)
|
|
nostrService.setPrivateKey(params.authorPrivateKey)
|
|
}
|
|
|
|
function finalizeEventFromTemplate(params: { eventTemplate: EventTemplate; authorPrivateKey: string }): Event {
|
|
const secretKey = hexToBytes(params.authorPrivateKey)
|
|
return finalizeEvent(params.eventTemplate, secretKey)
|
|
}
|
|
|
|
// moved to `articlePublisherPresentationHelpers.ts`
|