2026-01-14 01:08:33 +01:00

249 lines
7.8 KiB
TypeScript

import { finalizeEvent, nip19, SimplePool, type Event, type EventTemplate, type Filter } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import type { Article, NostrProfile } from '@/types/nostr'
import type { PublishResult } from '../publishResult'
import { checkZapReceipt as checkZapReceiptHelper } from '../nostrZapVerification'
import { createArticleSubscription, parseArticleOrPresentationFromEvent } from './articles'
import { getCachedArticleById } from './cache'
import { getDecryptedArticleContent, getPrivateContent } from './decryption'
import { buildImmediatePublishStatuses, buildUnsignedEventTemplate, publishEventNonBlocking } from './publish'
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 = buildUnsignedEventTemplate(eventTemplate)
const event = finalizeEvent(unsignedEvent, hexToBytes(this.privateKey))
const { relaySessionManager } = await import('../relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
void publishEventNonBlocking({ pool: this.pool, event, activeRelays, relaySessionManager })
if (returnStatus) {
const relayStatuses = await buildImmediatePublishStatuses(activeRelays)
return { event, relayStatuses }
}
return event
}
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 = createArticleSubscription({ pool: this.pool, limit })
sub.on('event', (event: Event): void => {
void (async (): Promise<void> => {
const parsed = await parseArticleOrPresentationFromEvent(event)
if (parsed) {
callback(parsed)
}
})()
})
return (): void => sub.unsub()
}
async getArticleById(eventId: string): Promise<Article | null> {
return getCachedArticleById(eventId)
}
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 getPrivateContent({ pool: this.pool, eventId, authorPubkey, privateKey: this.privateKey, publicKey: 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')
}
return getDecryptedArticleContent({ pool: this.pool, eventId, authorPubkey, privateKey: this.privateKey, publicKey: this.publicKey })
}
async getProfile(pubkey: string): Promise<NostrProfile | null> {
if (!this.pool) {
return null
}
const { getPrimaryRelaySync } = await import('../config')
const { createSubscription } = await import('@/types/nostr-tools-extended')
const relayUrl = getPrimaryRelaySync()
const filters: Filter[] = [{ kinds: [0], authors: [pubkey] }]
const sub = createSubscription(this.pool, [relayUrl], filters)
return subscribeToProfile({ sub, pubkey })
}
async createZapRequest(targetPubkey: string, targetEventId: string, amount: number, extraTags: string[][] = []): Promise<Event> {
if (!this.privateKey) {
throw new Error('Private key not set')
}
const { getPrimaryRelay } = await import('../config')
const relayUrl = await getPrimaryRelay()
const zapRequest: EventTemplate = {
kind: 9734,
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
}
checkZapReceipt(targetPubkey: string, targetEventId: string, amount: number, userPubkey?: string): Promise<boolean> {
if (!this.publicKey || !this.pool) {
return Promise.resolve(false)
}
return checkZapReceiptHelper({ pool: this.pool, targetPubkey, targetEventId, amount, userPubkey: userPubkey ?? this.publicKey })
}
getPool(): SimplePool | null {
return this.pool
}
}
async function subscribeToProfile(params: { sub: ReturnType<typeof import('@/types/nostr-tools-extended').createSubscription>; pubkey: string }): Promise<NostrProfile | null> {
return new Promise<NostrProfile | null>((resolve) => {
const resolved = { value: false }
let timeoutId: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeoutId) {
clearTimeout(timeoutId)
}
params.sub.unsub()
}
const resolveOnce = (value: NostrProfile | null): void => {
if (resolved.value) {
return
}
resolved.value = true
cleanup()
resolve(value)
}
params.sub.on('event', (event: Event): void => {
try {
const parsed = parseProfileEvent(event, params.pubkey)
if (parsed) {
resolveOnce(parsed)
}
} catch (e) {
console.error('Error parsing profile event:', e)
}
})
params.sub.on('eose', (): void => {
resolveOnce(null)
})
timeoutId = setTimeout(() => {
resolveOnce(null)
}, 5000)
})
}
function parseProfileEvent(event: Event, pubkey: string): NostrProfile | null {
if (event.kind !== 0) {
return null
}
if (event.pubkey !== pubkey) {
return null
}
try {
const parsed = JSON.parse(event.content) as Record<string, unknown>
const profile: NostrProfile = { pubkey }
if (typeof parsed.name === 'string') {
profile.name = parsed.name
}
if (typeof parsed.about === 'string') {
profile.about = parsed.about
}
if (typeof parsed.picture === 'string') {
profile.picture = parsed.picture
}
if (typeof parsed.nip05 === 'string') {
profile.nip05 = parsed.nip05
}
if (typeof parsed.lud16 === 'string') {
profile.lud16 = parsed.lud16
}
if (typeof parsed.lud06 === 'string') {
profile.lud06 = parsed.lud06
}
return profile
} catch (e) {
console.error('Error parsing profile JSON:', e)
return null
}
}
export const nostrService = new NostrService()