2026-01-07 11:00:13 +01:00

509 lines
17 KiB
TypeScript

import { Event, EventTemplate, finalizeEvent, nip19, SimplePool } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import type { Article, NostrProfile } from '@/types/nostr'
import { createSubscription } from '@/types/nostr-tools-extended'
import { parseArticleFromEvent } from './nostrEventParsing'
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
import {
getPrivateContent as getPrivateContentFromPool,
getDecryptionKey,
decryptArticleContentWithKey,
} from './nostrPrivateMessages'
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
import { buildTagFilter } from './nostrTagSystem'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import type { PublishResult, RelayPublishStatus } from './publishResult'
import { objectCache, type CachedObject } from './objectCache'
class NostrService {
private pool: SimplePool | null = null
private privateKey: string | null = null
private publicKey: string | null = null
constructor() {
if (typeof window !== 'undefined') {
this.initializePool()
}
}
private initializePool(): void {
this.pool = new SimplePool()
}
setPrivateKey(privateKey: string): void {
this.privateKey = privateKey
try {
const decoded = nip19.decode(privateKey)
if (decoded.type === 'nsec' && typeof decoded.data === 'string') {
this.privateKey = decoded.data
}
} catch {
// Assume it's already a hex string
}
}
getPrivateKey(): string | null {
return this.privateKey
}
getPublicKey(): string | null {
return this.publicKey
}
setPublicKey(publicKey: string): void {
this.publicKey = publicKey
try {
const decoded = nip19.decode(publicKey)
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
this.publicKey = decoded.data
}
} catch {
// Assume it's already a hex string
}
}
async publishEvent(eventTemplate: EventTemplate): Promise<Event | null>
async publishEvent(eventTemplate: EventTemplate, returnStatus: false): Promise<Event | null>
async publishEvent(eventTemplate: EventTemplate, returnStatus: true): Promise<PublishResult>
async publishEvent(eventTemplate: EventTemplate, returnStatus: boolean = false): Promise<Event | null | PublishResult> {
if (!this.privateKey || !this.pool) {
throw new Error('Private key not set or pool not initialized')
}
const unsignedEvent: EventTemplate = {
...eventTemplate,
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
}
const secretKey = hexToBytes(this.privateKey)
const event = finalizeEvent(unsignedEvent, secretKey)
// Publish to all active relays (enabled and not marked inactive for this session)
// Each event has a unique ID based on content, so publishing to multiple relays
// doesn't create duplicates - it's the same event stored redundantly
// Network failures do not block - we return the event and status immediately
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
const relayStatuses: RelayPublishStatus[] = []
// Start publishing asynchronously (don't await - non-blocking)
void (async (): Promise<void> => {
try {
if (activeRelays.length === 0) {
// Fallback to primary relay if no active relays
const relayUrl = await getPrimaryRelay()
if (!this.pool) {
throw new Error('Pool not initialized')
}
const pubs = this.pool.publish([relayUrl], event)
const results = await Promise.allSettled(pubs)
const successfulRelays: string[] = []
const { publishLog } = await import('./publishLog')
results.forEach((result) => {
if (result.status === 'fulfilled') {
successfulRelays.push(relayUrl)
// Log successful publication
void publishLog.logPublication(event.id, relayUrl, true, undefined)
} else {
const error = result.reason
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, error)
relaySessionManager.markRelayFailed(relayUrl)
// Log failed publication
void publishLog.logPublication(event.id, relayUrl, false, errorMessage)
}
})
// Update published status in IndexedDB
await this.updatePublishedStatus(event.id, successfulRelays.length > 0 ? successfulRelays : false)
} else {
// Publish to all active relays
console.warn(`[NostrService] Publishing event ${event.id} to ${activeRelays.length} active relay(s)`)
if (!this.pool) {
throw new Error('Pool not initialized')
}
const pubs = this.pool.publish(activeRelays, event)
// Track failed relays and mark them inactive for the session
const results = await Promise.allSettled(pubs)
const successfulRelays: string[] = []
const { publishLog } = await import('./publishLog')
results.forEach((result, index) => {
const relayUrl = activeRelays[index]
if (!relayUrl) {
return
}
if (result.status === 'fulfilled') {
successfulRelays.push(relayUrl)
// Log successful publication
void publishLog.logPublication(event.id, relayUrl, true, undefined)
} else {
const error = result.reason
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, error)
relaySessionManager.markRelayFailed(relayUrl)
// Log failed publication
void publishLog.logPublication(event.id, relayUrl, false, errorMessage)
}
})
// Update published status in IndexedDB
await this.updatePublishedStatus(event.id, successfulRelays.length > 0 ? successfulRelays : false)
}
} catch (publishError) {
console.error(`[NostrService] Error during publish (non-blocking):`, publishError)
// Mark as not published if all relays failed
await this.updatePublishedStatus(event.id, false)
}
})()
// Build statuses for return (synchronous, before network completes)
if (returnStatus) {
// Return statuses immediately (will be updated asynchronously)
activeRelays.forEach((relayUrl) => {
relayStatuses.push({
relayUrl,
success: false, // Will be updated asynchronously
})
})
return {
event,
relayStatuses,
}
}
// Return event immediately (non-blocking)
return event
}
private createArticleSubscription(pool: SimplePool, limit: number): ReturnType<typeof createSubscription> {
// Subscribe to both 'publication' and 'author' type events
// Authors are identified by tag type='author' in the tag system
// Filter by service='zapwall.fr' to only get notes from this platform
// Limit to events published on or after January 6, 2026
const filters = [
{
...buildTagFilter({
type: 'publication',
service: PLATFORM_SERVICE,
}),
since: MIN_EVENT_DATE,
limit,
},
{
...buildTagFilter({
type: 'author',
service: PLATFORM_SERVICE,
}),
since: MIN_EVENT_DATE,
limit,
},
]
const relayUrl = getPrimaryRelaySync()
return createSubscription(pool, [relayUrl], filters)
}
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
if (typeof window === 'undefined') {
throw new Error('Cannot subscribe on server side')
}
if (!this.pool) {
this.initializePool()
}
if (!this.pool) {
throw new Error('Pool not initialized')
}
const sub = this.createArticleSubscription(this.pool, limit)
sub.on('event', (event: Event): void => {
void (async (): Promise<void> => {
try {
// Try to parse as regular article first
let article = await parseArticleFromEvent(event)
// If not a regular article, try to parse as author presentation
if (!article) {
const presentation = await parsePresentationEvent(event)
if (presentation) {
article = presentation
}
}
if (article) {
callback(article)
}
} catch (parseError) {
console.error('Error parsing article:', parseError)
}
})()
})
return (): void => {
sub.unsub()
}
}
async getArticleById(eventId: string): Promise<Article | null> {
// Read only from IndexedDB cache
// Try by ID first
const cachedById = await objectCache.getById('publication', eventId)
if (cachedById) {
return cachedById as Article
}
// Also try by hash if eventId is a hash
const cachedByHash = await objectCache.get('publication', eventId)
if (cachedByHash) {
return cachedByHash as Article
}
// Also check author presentations
const cachedAuthor = await objectCache.getById('author', eventId)
if (cachedAuthor) {
return cachedAuthor as Article
}
const cachedAuthorByHash = await objectCache.get('author', eventId)
if (cachedAuthorByHash) {
return cachedAuthorByHash as Article
}
// Not found in cache - return null (no network request)
return null
}
getPrivateContent(eventId: string, authorPubkey: string): Promise<string | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
return getPrivateContentFromPool(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
}
/**
* Get and decrypt article content using decryption key from private message
* First retrieves the article event to get the encrypted content,
* then retrieves the decryption key from private messages,
* and finally decrypts the content
*/
private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
return null
}
return getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
}
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise<string | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
try {
const event = await this.getEventById(eventId)
if (!event) {
console.error('Event not found', { eventId, authorPubkey })
return null
}
const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey)
if (!decryptionKey) {
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
return null
}
return decryptArticleContentWithKey(event.content, decryptionKey)
} catch (decryptError) {
console.error('Error decrypting article content', {
eventId,
authorPubkey,
error: decryptError instanceof Error ? decryptError.message : 'Unknown error',
})
return null
}
}
/**
* Get event by ID (helper method)
* Reads only from IndexedDB cache
*/
private async getEventById(eventId: string): Promise<Event | null> {
// Read only from IndexedDB cache
// Try publication cache first
const eventFromPublication = await objectCache.getEventById('publication', eventId)
if (eventFromPublication) {
return eventFromPublication
}
// Try author cache
const eventFromAuthor = await objectCache.getEventById('author', eventId)
if (eventFromAuthor) {
return eventFromAuthor
}
// Not found in cache - return null (no network request)
return null
}
async getProfile(_pubkey: string): Promise<NostrProfile | null> {
// Read only from IndexedDB cache
// Profiles (kind 0) are not currently cached in objectCache
// Return null if profile is not in cache (no network request)
// TODO: Add profile caching to objectCache if needed
return null
}
/**
* Update Nostr profile (kind 0) with new metadata
* Merges new fields with existing profile data
*/
async updateProfile(updates: Partial<NostrProfile>): Promise<void> {
if (!this.privateKey || !this.publicKey) {
throw new Error('Private key and public key must be set to update profile')
}
// Get existing profile to merge with updates
const existingProfile = await this.getProfile(this.publicKey)
const currentProfile: NostrProfile = existingProfile ?? {
pubkey: this.publicKey,
}
// Merge updates with existing profile
const updatedProfile: NostrProfile = {
...currentProfile,
...updates,
pubkey: this.publicKey, // Always use current pubkey
}
// Create kind 0 event
const profileEvent: EventTemplate = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify({
name: updatedProfile.name,
about: updatedProfile.about,
picture: updatedProfile.picture,
nip05: updatedProfile.nip05,
lud16: updatedProfile.lud16,
lud06: updatedProfile.lud06,
}),
}
await this.publishEvent(profileEvent)
}
async createZapRequest(
targetPubkey: string,
targetEventId: string,
amount: number,
extraTags: string[][] = []
): Promise<Event> {
if (!this.privateKey) {
throw new Error('Private key not set')
}
const relayUrl = await getPrimaryRelay()
const zapRequest: EventTemplate = {
kind: 9734, // Zap request
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', targetPubkey],
['e', targetEventId],
['amount', amount.toString()],
['relays', relayUrl],
...extraTags,
],
content: '',
}
const event = await this.publishEvent(zapRequest)
if (!event) {
throw new Error('Failed to create zap request')
}
return event
}
// Check if user has paid for an article by looking for zap receipts
checkZapReceipt(
targetPubkey: string,
targetEventId: string,
amount: number,
userPubkey?: string
): Promise<boolean> {
if (!this.publicKey || !this.pool) {
return Promise.resolve(false)
}
// Use provided userPubkey or fall back to current public key
const checkPubkey = userPubkey ?? this.publicKey
return checkZapReceiptHelper(this.pool, targetPubkey, targetEventId, amount, checkPubkey)
}
/**
* Get the pool instance (for use by other services)
*/
getPool(): SimplePool | null {
return this.pool
}
/**
* Update published status in IndexedDB for an event
* Searches all object types to find and update the event
*/
private async updatePublishedStatus(eventId: string, published: false | string[]): Promise<void> {
const { objectCache } = await import('./objectCache')
const objectTypes: Array<import('./objectCache').ObjectType> = ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note']
// Load writeService once
const { writeService } = await import('./writeService')
// First try to find in unpublished objects (faster)
for (const objectType of objectTypes) {
try {
const unpublished = await objectCache.getUnpublished(objectType)
const matching = unpublished.find((obj) => obj.event.id === eventId)
if (matching) {
await writeService.updatePublished(objectType, matching.id, published)
return
}
} catch (error) {
console.warn(`[NostrService] Error checking unpublished in ${objectType}:`, error)
continue
}
}
// If not found in unpublished, search all objects
for (const objectType of objectTypes) {
try {
// Use getAll to search all objects
const allObjects = await objectCache.getAll(objectType)
const matching = allObjects.find((obj) => {
const cachedObj = obj as CachedObject
return cachedObj.event?.id === eventId
})
if (matching) {
const cachedObj = matching as CachedObject
await writeService.updatePublished(objectType, cachedObj.id, published)
return
}
} catch (error) {
console.warn(`[NostrService] Error searching for event in ${objectType}:`, error)
continue
}
}
}
}
export const nostrService = new NostrService()