235 lines
6.0 KiB
JavaScript
235 lines
6.0 KiB
JavaScript
import { bech32 } from 'bech32'
|
|
import { nip19 } from 'nostr-tools'
|
|
import WebSocket from 'isomorphic-ws'
|
|
import { callWithTimeout, withTimeout } from '@/lib/time'
|
|
import crypto from 'crypto'
|
|
|
|
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
|
|
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
|
|
export const NOSTR_MAX_RELAY_NUM = 20
|
|
export const NOSTR_ZAPPLE_PAY_NPUB = 'npub1wxl6njlcgygduct7jkgzrvyvd9fylj4pqvll6p32h59wyetm5fxqjchcan'
|
|
export const DEFAULT_CROSSPOSTING_RELAYS = [
|
|
'wss://nostrue.com/',
|
|
'wss://relay.damus.io/',
|
|
'wss://relay.nostr.band/',
|
|
'wss://relay.snort.social/',
|
|
'wss://nostr21.com/',
|
|
'wss://nostr.mutinywallet.com/',
|
|
'wss://relay.mutinywallet.com/'
|
|
]
|
|
|
|
export class Relay {
|
|
constructor (relayUrl) {
|
|
const ws = new WebSocket(relayUrl)
|
|
|
|
ws.onmessage = (msg) => {
|
|
const [type, notice] = JSON.parse(msg.data)
|
|
if (type === 'NOTICE') {
|
|
console.log('relay notice:', notice)
|
|
}
|
|
}
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('websocket error:', err.message)
|
|
this.error = err.message
|
|
}
|
|
|
|
this.ws = ws
|
|
this.url = relayUrl
|
|
this.error = null
|
|
}
|
|
|
|
static async connect (url, { timeout } = {}) {
|
|
const relay = new Relay(url)
|
|
await relay.waitUntilConnected({ timeout })
|
|
return relay
|
|
}
|
|
|
|
get connected () {
|
|
return this.ws.readyState === WebSocket.OPEN
|
|
}
|
|
|
|
get closed () {
|
|
return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED
|
|
}
|
|
|
|
async waitUntilConnected ({ timeout } = {}) {
|
|
let interval
|
|
|
|
const checkPromise = new Promise((resolve, reject) => {
|
|
interval = setInterval(() => {
|
|
if (this.connected) {
|
|
resolve()
|
|
}
|
|
if (this.closed) {
|
|
reject(new Error(`failed to connect to ${this.url}: ` + this.error))
|
|
}
|
|
}, 100)
|
|
})
|
|
|
|
try {
|
|
return await withTimeout(checkPromise, timeout)
|
|
} catch (err) {
|
|
this.close()
|
|
throw err
|
|
} finally {
|
|
clearInterval(interval)
|
|
}
|
|
}
|
|
|
|
close () {
|
|
const state = this.ws.readyState
|
|
if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) {
|
|
this.ws.close()
|
|
}
|
|
}
|
|
|
|
async publish (event, { timeout } = {}) {
|
|
const ws = this.ws
|
|
|
|
let listener
|
|
const ackPromise = new Promise((resolve, reject) => {
|
|
listener = function onmessage (msg) {
|
|
const [type, eventId, accepted, reason] = JSON.parse(msg.data)
|
|
|
|
if (type !== 'OK' || eventId !== event.id) return
|
|
|
|
if (accepted) {
|
|
resolve(eventId)
|
|
} else {
|
|
reject(new Error(reason || `event rejected: ${eventId}`))
|
|
}
|
|
}
|
|
|
|
ws.addEventListener('message', listener)
|
|
|
|
ws.send(JSON.stringify(['EVENT', event]))
|
|
})
|
|
|
|
try {
|
|
return await withTimeout(ackPromise, timeout)
|
|
} finally {
|
|
ws.removeEventListener('message', listener)
|
|
}
|
|
}
|
|
|
|
async fetch (filter, { timeout } = {}) {
|
|
const ws = this.ws
|
|
|
|
let listener
|
|
const ackPromise = new Promise((resolve, reject) => {
|
|
const id = crypto.randomBytes(16).toString('hex')
|
|
|
|
const events = []
|
|
let eose = false
|
|
|
|
listener = function onmessage (msg) {
|
|
const [type, subId, event] = JSON.parse(msg.data)
|
|
|
|
if (subId !== id) return
|
|
|
|
if (type === 'EVENT') {
|
|
events.push(event)
|
|
if (eose) {
|
|
// EOSE was already received:
|
|
// return first event after EOSE
|
|
resolve(events)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (type === 'CLOSED') {
|
|
return resolve(events)
|
|
}
|
|
|
|
if (type === 'EOSE') {
|
|
eose = true
|
|
if (events.length > 0) {
|
|
// we already received events before EOSE:
|
|
// return all events before EOSE
|
|
ws.send(JSON.stringify(['CLOSE', id]))
|
|
return resolve(events)
|
|
}
|
|
}
|
|
}
|
|
|
|
ws.addEventListener('message', listener)
|
|
|
|
ws.send(JSON.stringify(['REQ', id, ...filter]))
|
|
})
|
|
|
|
try {
|
|
return await withTimeout(ackPromise, timeout)
|
|
} finally {
|
|
ws.removeEventListener('message', listener)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hexToBech32 (hex, prefix = 'npub') {
|
|
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
|
|
}
|
|
|
|
export function nostrZapDetails (zap) {
|
|
let { pubkey, content, tags } = zap
|
|
let npub = hexToBech32(pubkey)
|
|
if (npub === NOSTR_ZAPPLE_PAY_NPUB) {
|
|
const znpub = content.match(/^From: nostr:(npub1[02-9ac-hj-np-z]+)$/)?.[1]
|
|
if (znpub) {
|
|
npub = znpub
|
|
// zapple pay does not support user content
|
|
content = null
|
|
}
|
|
}
|
|
const event = tags.filter(t => t?.length >= 2 && t[0] === 'e')?.[0]?.[1]
|
|
const note = event ? hexToBech32(event, 'note') : null
|
|
|
|
return { npub, content, note }
|
|
}
|
|
|
|
async function publishNostrEvent (signedEvent, relayUrl) {
|
|
const timeout = 3000
|
|
const relay = await Relay.connect(relayUrl, { timeout })
|
|
try {
|
|
await relay.publish(signedEvent, { timeout })
|
|
} finally {
|
|
relay.close()
|
|
}
|
|
}
|
|
|
|
export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
|
|
try {
|
|
const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 10000)
|
|
if (!signedEvent) throw new Error('failed to sign event')
|
|
|
|
const promises = relays.map(r => publishNostrEvent(signedEvent, r))
|
|
const results = await Promise.allSettled(promises)
|
|
const successfulRelays = []
|
|
const failedRelays = []
|
|
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successfulRelays.push(relays[index])
|
|
} else {
|
|
failedRelays.push({ relay: relays[index], error: result.reason })
|
|
}
|
|
})
|
|
|
|
let noteId = null
|
|
if (signedEvent.kind !== 1) {
|
|
noteId = await nip19.naddrEncode({
|
|
kind: signedEvent.kind,
|
|
pubkey: signedEvent.pubkey,
|
|
identifier: signedEvent.tags[0][1]
|
|
})
|
|
} else {
|
|
noteId = hexToBech32(signedEvent.id, 'note')
|
|
}
|
|
|
|
return { successfulRelays, failedRelays, noteId }
|
|
} catch (error) {
|
|
console.error('Crosspost error:', error)
|
|
return { error }
|
|
}
|
|
}
|