stacker.news/lib/nostr.js

204 lines
6.3 KiB
JavaScript

import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
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 const RELAYS_BLACKLIST = []
/* eslint-disable camelcase */
/**
* @import {NDKSigner} from '@nostr-dev-kit/ndk'
* @import { NDK } from '@nostr-dev-kit/ndk'
* @import {NDKNwc} from '@nostr-dev-kit/ndk'
* @typedef {Object} Nostr
* @property {NDK} ndk
* @property {function(string, {logger: Object}): Promise<NDKNwc>} nwc
* @property {function(Object, {privKey: string, signer: NDKSigner}): Promise<NDKEvent>} sign
* @property {function(Object, {relays: Array<string>, privKey: string, signer: NDKSigner}): Promise<NDKEvent>} publish
*/
export default class Nostr {
/**
* @type {NDK}
*/
_ndk = null
static globalInstance = null
constructor ({ privKey, defaultSigner, relays, nip46token, supportNip07 = false, ...ndkOptions } = {}) {
this._ndk = new NDK({
explicitRelayUrls: relays,
blacklistRelayUrls: RELAYS_BLACKLIST,
autoConnectUserRelays: false,
autoFetchUserMutelist: false,
clientName: 'stacker.news',
signer: defaultSigner ?? this.getSigner({ privKey, supportNip07, nip46token }),
...ndkOptions
})
}
/**
* @type {NDK}
*/
static get () {
if (!Nostr.globalInstance) {
Nostr.globalInstance = new Nostr()
}
return Nostr.globalInstance
}
/**
* @type {NDK}
*/
get ndk () {
return this._ndk
}
/**
*
* @param {Object} param0
* @param {string} [args.privKey] - private key to use for signing
* @param {string} [args.nip46token] - NIP-46 token to use for signing
* @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available
* @returns {NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | null} - a signer instance
*/
getSigner ({ privKey, nip46token, supportNip07 = true } = {}) {
if (privKey) return new NDKPrivateKeySigner(privKey)
if (nip46token) return new NDKNip46SignerURLPatch(this.ndk, nip46token)
if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer()
return null
}
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @returns {Promise<NDKEvent>}
*/
async sign ({ kind, created_at, content, tags }, { signer } = {}) {
const event = new NDKEvent(this.ndk, {
kind,
created_at,
content,
tags
})
signer ??= this.ndk.signer
if (!signer) throw new Error('no way to sign this event, please provide a signer or private key')
await event.sign(signer)
return event
}
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {Array<string>} context.relays
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @param {number} context.timeout
* @returns {Promise<NDKEvent>}
*/
async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) {
const event = await this.sign({ kind, created_at, content, tags }, { signer })
const successfulRelays = []
const failedRelays = []
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)
event.on('relay:publish:failed', (relay, error) => {
failedRelays.push({ relay: relay.url, error })
})
for (const relay of (await relaySet.publish(event, timeout))) {
successfulRelays.push(relay.url)
}
return {
event,
successfulRelays,
failedRelays
}
}
async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) {
try {
signer ??= this.getSigner({ supportNip07: true })
const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout })
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 }
}
}
/**
* Close all relay connections
*/
close () {
const pool = this.ndk.pool
for (const relay of pool.urls()) {
pool.removeRelay(relay)
}
}
}
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 }
}
// workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636)
class NDKNip46SignerURLPatch extends NDKNip46Signer {
connectionTokenInit (connectionToken) {
connectionToken = connectionToken.replace('bunker://', 'http://')
return super.connectionTokenInit(connectionToken)
}
}