story-research-zapwall/lib/articlePublisherPublish.ts
2026-01-07 02:06:09 +01:00

204 lines
6.5 KiB
TypeScript

import { nostrService } from './nostr'
import type { ArticleDraft, PublishedArticle } from './articlePublisherTypes'
import type { AlbyInvoice } from '@/types/alby'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { encryptArticleContent, encryptDecryptionKey } from './articleEncryption'
import { storePrivateContent } from './articleStorage'
import type { PublishResult } from './publishResult'
import { writeOrchestrator } from './writeOrchestrator'
import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import { generatePublicationHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator'
import type { Article } from '@/types/nostr'
export function buildFailure(error?: string): PublishedArticle {
const base: PublishedArticle = {
articleId: '',
previewEventId: '',
success: false,
}
return error ? { ...base, error } : base
}
async function buildParsedArticleFromDraft(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> {
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
const hashId = await generatePublicationHashId({
pubkey: authorPubkey,
title: draft.title,
preview: draft.preview,
category,
seriesId: draft.seriesId ?? undefined,
bannerUrl: draft.bannerUrl ?? undefined,
zapAmount: draft.zapAmount,
})
const hash = hashId
const version = 0
const index = 0
const id = buildObjectId(hash, index, version)
const article: Article = {
id,
hash,
version,
index,
pubkey: authorPubkey,
title: draft.title,
preview: draft.preview,
content: '',
description: draft.preview,
contentDescription: draft.preview,
createdAt: Math.floor(Date.now() / 1000),
zapAmount: draft.zapAmount,
paid: false,
thumbnailUrl: draft.bannerUrl ?? '',
invoice: invoice.invoice,
paymentHash: invoice.paymentHash ?? undefined,
category: draft.category,
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}),
kindType: 'article',
}
return { article, hash, version, index }
}
export async function publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string,
presentationId: string,
extraTags?: string[][],
encryptedContent?: string,
encryptedKey?: string,
returnStatus?: boolean
): Promise<import('nostr-tools').Event | null | PublishResult> {
// Build parsed article object
const { article, hash, version, index } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey)
// Build event template
const previewEventTemplate = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey)
// Set private key in orchestrator
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey)
// Finalize event
const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(previewEventTemplate, secretKey)
// Get active relays
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
const { getPrimaryRelay } = await import('./config')
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
// Publish via writeOrchestrator (parallel network + local write)
const result = await writeOrchestrator.writeAndPublish(
{
objectType: 'publication',
hash,
event,
parsed: article,
version,
hidden: false,
index,
},
relays
)
if (!result.success) {
return null
}
if (returnStatus) {
// Return PublishResult format
const { publishResult } = await import('./publishResult')
return {
event,
relayStatuses: relays.map((relayUrl, idx) => {
const isSuccess = typeof result.published === 'object' && result.published.includes(relayUrl)
return {
relayUrl,
success: isSuccess,
error: isSuccess ? undefined : 'Failed to publish',
}
}),
}
}
return event
}
export function buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable<ArticleDraft['category']>): 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
}
export async function encryptAndPublish(
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKeyForEncryption: string,
category: NonNullable<ArticleDraft['category']>,
presentationId: string
): Promise<PublishedArticle> {
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
const invoice = await createArticleInvoice(draft)
const extraTags = buildArticleExtraTags(draft, category)
const publishResult = await publishPreview(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey, true)
if (!publishResult) {
return buildFailure('Failed to publish article')
}
// Handle both old format (Event | null) and new format (PublishResult)
let event: import('nostr-tools').Event | null = null
let relayStatuses: import('./publishResult').RelayPublishStatus[] | undefined
if (publishResult && 'event' in publishResult && 'relayStatuses' in publishResult) {
// New format with statuses
event = publishResult.event
relayStatuses = publishResult.relayStatuses
} else if (publishResult && 'id' in publishResult) {
// Old format (Event)
event = publishResult
}
if (!event) {
return buildFailure('Failed to publish article')
}
await storePrivateContent(event.id, draft.content, authorPubkey, invoice, key, iv)
console.warn('Article published with encrypted content', {
articleId: event.id,
authorPubkey,
timestamp: new Date().toISOString(),
relayStatuses,
})
return {
articleId: event.id,
previewEventId: event.id,
invoice,
success: true,
relayStatuses,
}
}