249 lines
7.8 KiB
TypeScript
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()
|