Put everything in src directories
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {GROUP, COMMUNITY} from './Kinds'
|
||||
|
||||
// Define this locally to avoid circular dependencies
|
||||
type AddressableEvent = {
|
||||
kind: number
|
||||
pubkey: string
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
export class Address {
|
||||
constructor(
|
||||
readonly kind: number,
|
||||
readonly pubkey: string,
|
||||
readonly identifier: string,
|
||||
readonly relays: string[] = []
|
||||
) {}
|
||||
|
||||
static isAddress(address: string) {
|
||||
return Boolean(address.match(/^\d+:\w+:.*$/))
|
||||
}
|
||||
|
||||
static from(address: string, relays: string[] = []) {
|
||||
const [kind, pubkey, identifier = ""] = address.match(/^(\d+):(\w+):(.*)$/)!.slice(1)
|
||||
|
||||
return new Address(parseInt(kind), pubkey, identifier, relays)
|
||||
}
|
||||
|
||||
static fromNaddr(naddr: string) {
|
||||
let type
|
||||
let data = {} as any
|
||||
try {
|
||||
({type, data} = nip19.decode(naddr) as {
|
||||
type: "naddr"
|
||||
data: any
|
||||
})
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
|
||||
if (type !== "naddr") {
|
||||
throw new Error(`Invalid naddr ${naddr}`)
|
||||
}
|
||||
|
||||
return new Address(data.kind, data.pubkey, data.identifier, data.relays)
|
||||
}
|
||||
|
||||
static fromEvent(event: AddressableEvent, relays: string[] = []) {
|
||||
const identifier = event.tags.find(t => t[0] === "d")?.[1] || ""
|
||||
|
||||
return new Address(event.kind, event.pubkey, identifier, relays)
|
||||
}
|
||||
|
||||
toString = () => [this.kind, this.pubkey, this.identifier].join(":")
|
||||
|
||||
toNaddr = () => nip19.naddrEncode(this)
|
||||
}
|
||||
|
||||
// Utils
|
||||
|
||||
export const getAddress = (e: AddressableEvent) => Address.fromEvent(e).toString()
|
||||
|
||||
export const isGroupAddress = (a: string, ...args: unknown[]) => Address.from(a).kind === GROUP
|
||||
|
||||
export const isCommunityAddress = (a: string, ...args: unknown[]) => Address.from(a).kind === COMMUNITY
|
||||
|
||||
export const isContextAddress = (a: string, ...args: unknown[]) => [GROUP, COMMUNITY].includes(Address.from(a).kind)
|
||||
@@ -0,0 +1,118 @@
|
||||
import {verifiedSymbol} from 'nostr-tools'
|
||||
import {verifyEvent, getEventHash} from 'nostr-tools'
|
||||
import {cached, pick, now} from '@welshman/lib'
|
||||
import {Tags} from './Tags'
|
||||
import {getAddress} from './Address'
|
||||
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
|
||||
|
||||
export type EventTemplate = {
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type OwnedEvent = EventTemplate & {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export type HashedEvent = OwnedEvent & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SignedEvent = HashedEvent & {
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type UnwrappedEvent = HashedEvent & {
|
||||
wrap: SignedEvent
|
||||
}
|
||||
|
||||
export type TrustedEvent = HashedEvent & {
|
||||
sig?: string
|
||||
wrap?: SignedEvent
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export type CreateEventOpts = {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export const createEvent = (kind: number, {content = "", tags = [], created_at = now()}: CreateEventOpts) =>
|
||||
({kind, content, tags, created_at})
|
||||
|
||||
export const isEventTemplate = (e: EventTemplate): e is EventTemplate =>
|
||||
Boolean(typeof e.kind === "number" && e.tags && typeof e.content === "string" && e.created_at)
|
||||
|
||||
export const isOwnedEvent = (e: OwnedEvent): e is OwnedEvent =>
|
||||
Boolean(isEventTemplate(e) && e.pubkey)
|
||||
|
||||
export const isHashedEvent = (e: HashedEvent): e is HashedEvent =>
|
||||
Boolean(isOwnedEvent(e) && e.id)
|
||||
|
||||
export const isSignedEvent = (e: TrustedEvent): e is SignedEvent =>
|
||||
Boolean(isHashedEvent(e) && e.sig)
|
||||
|
||||
export const isUnwrappedEvent = (e: TrustedEvent): e is UnwrappedEvent =>
|
||||
Boolean(isHashedEvent(e) && e.wrap)
|
||||
|
||||
export const isTrustedEvent = (e: TrustedEvent): e is TrustedEvent =>
|
||||
isSignedEvent(e) || isUnwrappedEvent(e)
|
||||
|
||||
export const asEventTemplate = (e: EventTemplate): EventTemplate =>
|
||||
pick(['kind', 'tags', 'content', 'created_at'], e)
|
||||
|
||||
export const asOwnedEvent = (e: OwnedEvent): OwnedEvent =>
|
||||
pick(['kind', 'tags', 'content', 'created_at', 'pubkey'], e)
|
||||
|
||||
export const asHashedEvent = (e: HashedEvent): HashedEvent =>
|
||||
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id'], e)
|
||||
|
||||
export const asSignedEvent = (e: SignedEvent): SignedEvent =>
|
||||
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'sig'], e)
|
||||
|
||||
export const asUnwrappedEvent = (e: UnwrappedEvent): UnwrappedEvent =>
|
||||
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'wrap'], e)
|
||||
|
||||
export const asTrustedEvent = (e: TrustedEvent): TrustedEvent =>
|
||||
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'sig', 'wrap'], e)
|
||||
|
||||
export const hasValidSignature = cached<string, boolean, [SignedEvent]>({
|
||||
maxSize: 10000,
|
||||
getKey: ([e]: [SignedEvent]) => {
|
||||
try {
|
||||
return [getEventHash(e), e.sig].join(":")
|
||||
} catch (err) {
|
||||
return 'invalid'
|
||||
}
|
||||
},
|
||||
getValue: ([e]: [SignedEvent]) => {
|
||||
try {
|
||||
return verifyEvent(e)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getIdOrAddress = (e: HashedEvent) => isReplaceable(e) ? getAddress(e) : e.id
|
||||
|
||||
export const getIdAndAddress = (e: HashedEvent) => isReplaceable(e) ? [e.id, getAddress(e)] : [e.id]
|
||||
|
||||
export const isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind)
|
||||
|
||||
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
||||
|
||||
export const isPlainReplaceable = (e: EventTemplate) => isPlainReplaceableKind(e.kind)
|
||||
|
||||
export const isParameterizedReplaceable = (e: EventTemplate) => isParameterizedReplaceableKind(e.kind)
|
||||
|
||||
export const isChildOf = (child: EventTemplate, parent: HashedEvent) => {
|
||||
const {roots, replies} = Tags.fromEvent(child).ancestors()
|
||||
const parentIds = (replies.exists() ? replies : roots).values().valueOf()
|
||||
|
||||
return getIdAndAddress(parent).some(x => parentIds.includes(x))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {Event} from 'nostr-tools'
|
||||
import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
|
||||
import {uniqBy, prop, mapVals, shuffle, avg, hash, groupBy, randomId, uniq} from '@welshman/lib'
|
||||
import type {HashedEvent, TrustedEvent} from './Events'
|
||||
import {isReplaceableKind} from './Kinds'
|
||||
import {Address, getAddress} from './Address'
|
||||
|
||||
export const EPOCH = 1609459200
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[]
|
||||
}
|
||||
|
||||
export const matchFilter = <E extends HashedEvent>(filter: Filter, event: E) => {
|
||||
if (!nostrToolsMatchFilter(filter, event as unknown as Event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const content = event.content.toLowerCase()
|
||||
const terms = filter.search.toLowerCase().split(/\s+/g)
|
||||
|
||||
for (const term of terms) {
|
||||
if (content.includes(term)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const matchFilters = <E extends HashedEvent>(filters: Filter[], event: E) => {
|
||||
for (const filter of filters) {
|
||||
if (matchFilter(filter, event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const getFilterId = (filter: Filter) => {
|
||||
const keys = Object.keys(filter)
|
||||
|
||||
keys.sort()
|
||||
|
||||
const parts = []
|
||||
for (const k of keys) {
|
||||
const v = filter[k as keyof Filter]
|
||||
const s = Array.isArray(v) ? v.join(',') : v
|
||||
|
||||
parts.push([k, s].join(':'))
|
||||
}
|
||||
|
||||
return hash(parts.join('|'))
|
||||
}
|
||||
|
||||
export const calculateFilterGroup = ({since, until, limit, search, ...filter}: Filter) => {
|
||||
const group = Object.keys(filter)
|
||||
|
||||
if (since) group.push(`since:${since}`)
|
||||
if (until) group.push(`until:${until}`)
|
||||
if (limit) group.push(`limit:${randomId()}`)
|
||||
if (search) group.push(`search:${search}`)
|
||||
|
||||
return group.sort().join("-")
|
||||
}
|
||||
|
||||
export const unionFilters = (filters: Filter[]) => {
|
||||
const result = []
|
||||
|
||||
// Group, but also get unique filters by ids because duplicates can come through subscribe
|
||||
for (const group of groupBy(calculateFilterGroup, uniqBy(getFilterId, filters)).values()) {
|
||||
const newFilter: Record<string, any> = {}
|
||||
|
||||
for (const k of Object.keys(group[0])) {
|
||||
if (["since", "until", "limit", "search"].includes(k)) {
|
||||
newFilter[k] = (group[0] as Record<string, any>)[k]
|
||||
} else {
|
||||
newFilter[k] = uniq(group.flatMap(prop(k)))
|
||||
}
|
||||
}
|
||||
|
||||
result.push(newFilter as Filter)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const intersectFilters = (groups: Filter[][]) => {
|
||||
let result = groups[0]
|
||||
|
||||
for (const filters of groups.slice(1)) {
|
||||
result = result.flatMap(f1 => {
|
||||
return filters.map(f2 => {
|
||||
const f3: Filter = {}
|
||||
|
||||
for (const k of uniq([...Object.keys(f1), ...Object.keys(f2)]) as (keyof Filter)[]) {
|
||||
if (k === 'since' || k === 'limit') {
|
||||
f3[k] = Math.max(f1[k] || 0, f2[k] || 0)
|
||||
} else if (k === 'until') {
|
||||
f3[k] = Math.min(f1[k] || f2[k] || 0, f2[k] || f1[k] || 0)
|
||||
} else if (k === 'search') {
|
||||
if (f1[k] && f2[k] && f1[k] !== f2[k]) {
|
||||
f3[k] = [f1[k], f2[k]].join(' ')
|
||||
} else {
|
||||
f3[k] = f1[k] || f2[k]
|
||||
}
|
||||
} else {
|
||||
f3[k] = uniq([...(f1[k] || []), ...(f2[k] || [])]) as any[]
|
||||
}
|
||||
}
|
||||
|
||||
return f3
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return unionFilters(result)
|
||||
}
|
||||
|
||||
export const getIdFilters = (idsOrAddresses: string[]) => {
|
||||
const ids = []
|
||||
const aFilters = []
|
||||
|
||||
for (const idOrAddress of idsOrAddresses) {
|
||||
if (Address.isAddress(idOrAddress)) {
|
||||
const {kind, pubkey, identifier} = Address.from(idOrAddress)
|
||||
|
||||
if (identifier) {
|
||||
aFilters.push({kinds: [kind], authors: [pubkey], "#d": [identifier]})
|
||||
} else {
|
||||
aFilters.push({kinds: [kind], authors: [pubkey]})
|
||||
}
|
||||
} else {
|
||||
ids.push(idOrAddress)
|
||||
}
|
||||
}
|
||||
|
||||
const filters = unionFilters(aFilters)
|
||||
|
||||
if (ids.length > 0) {
|
||||
filters.push({ids})
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
export const getReplyFilters = (events: TrustedEvent[], filter: Filter) => {
|
||||
const a = []
|
||||
const e = []
|
||||
|
||||
for (const event of events) {
|
||||
e.push(event.id)
|
||||
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
a.push(getAddress(event))
|
||||
}
|
||||
|
||||
if (event.wrap) {
|
||||
e.push(event.wrap.id)
|
||||
}
|
||||
}
|
||||
|
||||
const filters = []
|
||||
|
||||
if (a.length > 0) {
|
||||
filters.push({...filter, "#a": a})
|
||||
}
|
||||
|
||||
if (e.length > 0) {
|
||||
filters.push({...filter, "#e": e})
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
export const getFilterGenerality = (filter: Filter) => {
|
||||
if (filter.ids || filter["#e"] || filter["#a"]) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const hasTags = Object.keys(filter).find((k: string) => k.startsWith("#"))
|
||||
|
||||
if (filter.authors && hasTags) {
|
||||
return 0.2
|
||||
}
|
||||
|
||||
if (filter.authors) {
|
||||
return Math.min(1, filter.authors.length / 100)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export const guessFilterDelta = (filters: Filter[], max = 60 * 60 * 24 * 7) =>
|
||||
Math.round(max * Math.max(0.005, 1 - avg(filters.map(getFilterGenerality))))
|
||||
|
||||
// If a filter is specifying ids, we know how many results to expect
|
||||
export const getFilterResultCardinality = (filter: Filter) => {
|
||||
if (filter.ids) {
|
||||
return filter.ids.length
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const trimFilter = (filter: Filter) =>
|
||||
mapVals(v => Array.isArray(v) && v.length > 1000 ? shuffle(v).slice(0, 1000) : v, filter)
|
||||
|
||||
export const trimFilters = (filters: Filter[]) => filters.map(trimFilter)
|
||||
@@ -0,0 +1,149 @@
|
||||
import {kinds} from 'nostr-tools'
|
||||
|
||||
export const isRegularKind = kinds.isRegularKind
|
||||
export const isEphemeralKind = kinds.isEphemeralKind
|
||||
export const isPlainReplaceableKind = kinds.isReplaceableKind
|
||||
export const isParameterizedReplaceableKind = kinds.isParameterizedReplaceableKind
|
||||
export const isReplaceableKind = (kind: number) =>
|
||||
isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind)
|
||||
|
||||
export const PROFILE = 0
|
||||
export const NOTE = 1
|
||||
export const FOLLOWS = 3
|
||||
export const DELETE = 5
|
||||
export const REPOST = 6
|
||||
export const REACTION = 7
|
||||
export const BADGE_AWARD = 8
|
||||
export const GROUP_CHAT_MESSAGE = 9
|
||||
export const GROUP_CHAT_REPLY = 10
|
||||
export const GROUP_CHAT_THREAD = 11
|
||||
export const GROUP_CHAT_THREAD_REPLY = 12
|
||||
export const SEAL = 13
|
||||
export const DIRECT_MESSAGE = 14
|
||||
export const READ_RECEIPT = 15
|
||||
export const GENERIC_REPOST = 16
|
||||
export const CHANNEL_CREATE = 40
|
||||
export const CHANNEL_UPDATE = 41
|
||||
export const CHANNEL_MESSAGE = 42
|
||||
export const CHANNEL_HIDE_MESSAGE = 43
|
||||
export const CHANNEL_MUTE_USER = 44
|
||||
export const BID = 1021
|
||||
export const BID_CONFIRMATION = 1022
|
||||
export const OTS = 1040
|
||||
export const WRAP = 1059
|
||||
export const WRAP_NIP04 = 1060
|
||||
export const FILE_METADATA = 1063
|
||||
export const LIVE_CHAT_MESSAGE = 1311
|
||||
export const GIT_PATCH = 1617
|
||||
export const GIT_ISSUE = 1617
|
||||
export const GIT_REPLY = 1617
|
||||
export const GIT_STATUS_OPEN = 1630
|
||||
export const GIT_STATUS_COMPLETE = 1631
|
||||
export const GIT_STATUS_CLOSED = 1632
|
||||
export const GIT_STATUS_DRAFT = 1633
|
||||
export const GIT_REPOSITORY = 30403
|
||||
export const REMIX = 1808
|
||||
export const NOSTROCKER_PROBLEM = 1971
|
||||
export const REPORT = 1984
|
||||
export const LABEL = 1985
|
||||
export const REVIEW = 1986
|
||||
export const APPROVAL = 4550
|
||||
export const DVM_REQUEST_TEXT_EXTRACTION = 5000
|
||||
export const DVM_REQUEST_TEXT_SUMMARY = 5001
|
||||
export const DVM_REQUEST_TEXT_TRANSLATION = 5002
|
||||
export const DVM_REQUEST_TEXT_GENERATION = 5050
|
||||
export const DVM_REQUEST_IMAGE_GENERATION = 5100
|
||||
export const DVM_REQUEST_VIDEO_CONVERSION = 5200
|
||||
export const DVM_REQUEST_VIDEO_TRANSLATION = 5201
|
||||
export const DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
|
||||
export const DVM_REQUEST_TEXT_TO_SPEECH = 5250
|
||||
export const DVM_REQUEST_DISCOVER_CONTENT = 5300
|
||||
export const DVM_REQUEST_DISCOVER_PEOPLE = 5301
|
||||
export const DVM_REQUEST_SEARCH_CONTENT = 5302
|
||||
export const DVM_REQUEST_SEARCH_PEOPLE = 5303
|
||||
export const DVM_REQUEST_COUNT = 5400
|
||||
export const DVM_REQUEST_MALWARE_SCAN = 5500
|
||||
export const DVM_REQUEST_OTS = 5900
|
||||
export const DVM_REQUEST_OP_RETURN = 5901
|
||||
export const DVM_REQUEST_PUBLISH_SCHEDULE = 5905
|
||||
export const DVM_RESPONSE_TEXT_EXTRACTION = 6000
|
||||
export const DVM_RESPONSE_TEXT_SUMMARY = 6001
|
||||
export const DVM_RESPONSE_TEXT_TRANSLATION = 6002
|
||||
export const DVM_RESPONSE_TEXT_GENERATION = 6050
|
||||
export const DVM_RESPONSE_IMAGE_GENERATION = 6100
|
||||
export const DVM_RESPONSE_VIDEO_CONVERSION = 6200
|
||||
export const DVM_RESPONSE_VIDEO_TRANSLATION = 6201
|
||||
export const DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
|
||||
export const DVM_RESPONSE_TEXT_TO_SPEECH = 6250
|
||||
export const DVM_RESPONSE_DISCOVER_CONTENT = 6300
|
||||
export const DVM_RESPONSE_DISCOVER_PEOPLE = 6301
|
||||
export const DVM_RESPONSE_SEARCH_CONTENT = 6302
|
||||
export const DVM_RESPONSE_SEARCH_PEOPLE = 6303
|
||||
export const DVM_RESPONSE_COUNT = 6400
|
||||
export const DVM_RESPONSE_MALWARE_SCAN = 6500
|
||||
export const DVM_RESPONSE_OTS = 6900
|
||||
export const DVM_RESPONSE_OP_RETURN = 6901
|
||||
export const DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
|
||||
export const DVM_FEEDBACK = 7000
|
||||
export const ZAP_GOAL = 9041
|
||||
export const ZAP_REQUEST = 9734
|
||||
export const ZAP_RESPONSE = 9735
|
||||
export const HIGHLIGHT = 9802
|
||||
export const MUTES = 10000
|
||||
export const PINS = 10001
|
||||
export const RELAYS = 10002
|
||||
export const BOOKMARKS = 10003
|
||||
export const COMMUNITIES = 10004
|
||||
export const CHANNELS = 10005
|
||||
export const BLOCKED_RELAYS = 10006
|
||||
export const SEARCH_RELAYS = 10007
|
||||
export const GROUPS = 10009
|
||||
export const TOPICS = 10015
|
||||
export const EMOJIS = 10030
|
||||
export const DM_INBOX_RELAYS = 10050
|
||||
export const FILE_SERVERS = 10096
|
||||
export const LIGHTNING_PUB_RPC = 21000
|
||||
export const CLIENT_AUTH = 22242
|
||||
export const WALLET_INFO = 13194
|
||||
export const WALLET_REQUEST = 23194
|
||||
export const WALLET_RESPONSE = 23195
|
||||
export const NOSTR_CONNECT = 24133
|
||||
export const HTTP_AUTH = 27235
|
||||
export const NAMED_PEOPLE = 30000
|
||||
export const NAMED_RELAYS = 30002
|
||||
export const NAMED_BOOKMARKS = 30003
|
||||
export const NAMED_CURATIONS = 30004
|
||||
export const NAMED_WIKI_AUTHORS = 30101
|
||||
export const NAMED_WIKI_RELAYS = 30102
|
||||
export const NAMED_EMOJIS = 30030
|
||||
export const NAMED_TOPICS = 30015
|
||||
export const NAMED_ARTIFACTS = 30063
|
||||
export const BADGES = 30008
|
||||
export const BADGE_DEFINITION = 30009
|
||||
export const STALL = 30017
|
||||
export const PRODUCT = 30018
|
||||
export const MARKET_UI = 30019
|
||||
export const PRODUCT_SOLD_AS_AUCTION = 30020
|
||||
export const WIKI = 30818
|
||||
export const LONG_FORM = 30023
|
||||
export const LONG_FORM_DRAFT = 30024
|
||||
export const APP_DATA = 30078
|
||||
export const LIVE_EVENT = 30311
|
||||
export const STATUS = 30315
|
||||
export const CLASSIFIED = 30402
|
||||
export const DRAFT_CLASSIFIED = 30403
|
||||
export const AUDIO = 31337
|
||||
export const FEED = 31890
|
||||
export const CALENDAR = 31924
|
||||
export const EVENT_DATE = 31922
|
||||
export const EVENT_TIME = 31923
|
||||
export const EVENT_RSVP = 31925
|
||||
export const HANDLER_RECOMMENDATION = 31989
|
||||
export const HANDLER_INFORMATION = 31990
|
||||
export const COMMUNITY = 34550
|
||||
export const GROUP = 35834
|
||||
|
||||
export const DEPRECATED_RELAY_RECOMMENDATION = 2
|
||||
export const DEPRECATED_DIRECT_MESSAGE = 4
|
||||
export const DEPRECATED_NAMED_GENERIC = 30001
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const fromNostrURI = (s: string) => s.replace(/^nostr:\/?\/?/, "")
|
||||
|
||||
export const toNostrURI = (s: string) => `nostr:${s}`
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Emitter, normalizeUrl, sleep, stripProtocol} from '@welshman/lib'
|
||||
import {matchFilters} from './Filters'
|
||||
import type {Repository} from './Repository'
|
||||
import type {Filter} from './Filters'
|
||||
import type {TrustedEvent} from './Events'
|
||||
|
||||
export const LOCAL_RELAY_URL = "local://welshman.relay"
|
||||
|
||||
export const BOGUS_RELAY_URL = "bogus://welshman.relay"
|
||||
|
||||
export const isShareableRelayUrl = (url: string) =>
|
||||
Boolean(
|
||||
typeof url === 'string' &&
|
||||
// Is it actually a websocket url and has a dot
|
||||
url.match(/^wss:\/\/.+\..+/) &&
|
||||
// Sometimes bugs cause multiple relays to get concatenated
|
||||
url.match(/:\/\//g)?.length === 1 &&
|
||||
// It shouldn't have any whitespace, url-encoded or otherwise
|
||||
!url.match(/\s|%/) &&
|
||||
// Don't match stuff with a port number
|
||||
!url.slice(6).match(/:\d+/) &&
|
||||
// Don't match stuff with a numeric tld
|
||||
!url.slice(6).match(/\.\d+\b/) &&
|
||||
// Don't match raw ip addresses
|
||||
!url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) &&
|
||||
// Skip nostr.wine's virtual relays
|
||||
!url.slice(6).match(/\/npub/)
|
||||
)
|
||||
|
||||
type NormalizeRelayUrlOpts = {
|
||||
allowInsecure?: boolean
|
||||
}
|
||||
|
||||
export const normalizeRelayUrl = (url: string, {allowInsecure = false}: NormalizeRelayUrlOpts = {}) => {
|
||||
const prefix = allowInsecure ? url.match(/^wss?:\/\//)?.[0] || "wss://" : "wss://"
|
||||
|
||||
// Use our library to normalize
|
||||
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
|
||||
|
||||
// Strip the protocol since only wss works, lowercase
|
||||
url = stripProtocol(url).toLowerCase()
|
||||
|
||||
// Urls without pathnames are supposed to have a trailing slash
|
||||
if (!url.includes("/")) {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
return prefix + url
|
||||
}
|
||||
|
||||
export class Relay extends Emitter {
|
||||
subs = new Map<string, Filter[]>()
|
||||
|
||||
constructor(readonly repository: Repository) {
|
||||
super()
|
||||
}
|
||||
|
||||
send(type: string, ...message: any[]) {
|
||||
switch(type) {
|
||||
case 'EVENT': return this.handleEVENT(message as [TrustedEvent])
|
||||
case 'CLOSE': return this.handleCLOSE(message as [string])
|
||||
case 'REQ': return this.handleREQ(message as [string, ...Filter[]])
|
||||
}
|
||||
}
|
||||
|
||||
handleEVENT([event]: [TrustedEvent]) {
|
||||
this.repository.publish(event)
|
||||
|
||||
// Callers generally expect async relays
|
||||
sleep(1).then(() => {
|
||||
this.emit('OK', event.id, true, "")
|
||||
|
||||
if (!this.repository.isDeleted(event)) {
|
||||
for (const [subId, filters] of this.subs.entries()) {
|
||||
if (matchFilters(filters, event)) {
|
||||
this.emit('EVENT', subId, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleCLOSE([subId]: [string]) {
|
||||
this.subs.delete(subId)
|
||||
}
|
||||
|
||||
handleREQ([subId, ...filters]: [string, ...Filter[]]) {
|
||||
this.subs.set(subId, filters)
|
||||
|
||||
// Callers generally expect async relays
|
||||
sleep(1).then(() => {
|
||||
for (const event of this.repository.query(filters)) {
|
||||
this.emit('EVENT', subId, event)
|
||||
}
|
||||
|
||||
this.emit('EOSE', subId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {flatten, Emitter, sortBy, inc, chunk, sleep, uniq, omit, now, range, identity} from '@welshman/lib'
|
||||
import {DELETE} from './Kinds'
|
||||
import {EPOCH, matchFilter} from './Filters'
|
||||
import {isReplaceable, isTrustedEvent} from './Events'
|
||||
import {getAddress} from './Address'
|
||||
import type {Filter} from './Filters'
|
||||
import type {TrustedEvent} from './Events'
|
||||
|
||||
export const DAY = 86400
|
||||
|
||||
const getDay = (ts: number) => Math.floor(ts / DAY)
|
||||
|
||||
export class Repository extends Emitter {
|
||||
eventsById = new Map<string, TrustedEvent>()
|
||||
eventsByWrap = new Map<string, TrustedEvent>()
|
||||
eventsByAddress = new Map<string, TrustedEvent>()
|
||||
eventsByTag = new Map<string, TrustedEvent[]>()
|
||||
eventsByDay = new Map<number, TrustedEvent[]>()
|
||||
eventsByAuthor = new Map<string, TrustedEvent[]>()
|
||||
deletes = new Map<string, number>()
|
||||
|
||||
// Dump/load/clear
|
||||
|
||||
dump = () => {
|
||||
return Array.from(this.eventsById.values())
|
||||
}
|
||||
|
||||
load = async (events: TrustedEvent[], chunkSize = 1000) => {
|
||||
this.clear()
|
||||
|
||||
for (const eventsChunk of chunk(chunkSize, events)) {
|
||||
for (const event of eventsChunk) {
|
||||
this.publish(event, {shouldNotify: false})
|
||||
}
|
||||
|
||||
if (eventsChunk.length === chunkSize) {
|
||||
await sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.emit('update', {
|
||||
added: events,
|
||||
removed: new Set(this.deletes.keys()),
|
||||
})
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
this.eventsById.clear()
|
||||
this.eventsByWrap.clear()
|
||||
this.eventsByAddress.clear()
|
||||
this.eventsByTag.clear()
|
||||
this.eventsByDay.clear()
|
||||
this.eventsByAuthor.clear()
|
||||
this.deletes.clear()
|
||||
}
|
||||
|
||||
// API
|
||||
|
||||
getEvent = (idOrAddress: string) => {
|
||||
return idOrAddress.includes(':')
|
||||
? this.eventsByAddress.get(idOrAddress)
|
||||
: this.eventsById.get(idOrAddress)
|
||||
}
|
||||
|
||||
hasEvent = (event: TrustedEvent) => {
|
||||
const duplicate = (
|
||||
this.eventsById.get(event.id) ||
|
||||
this.eventsByAddress.get(getAddress(event))
|
||||
)
|
||||
|
||||
return duplicate && duplicate.created_at >= event.created_at
|
||||
}
|
||||
|
||||
query = (filters: Filter[], {includeDeleted = false} = {}) => {
|
||||
const result: TrustedEvent[][] = []
|
||||
for (let filter of filters) {
|
||||
let events: TrustedEvent[] = Array.from(this.eventsById.values())
|
||||
|
||||
if (filter.ids) {
|
||||
events = filter.ids!.map(id => this.eventsById.get(id)).filter(identity) as TrustedEvent[]
|
||||
filter = omit(['ids'], filter)
|
||||
} else if (filter.authors) {
|
||||
events = uniq(filter.authors!.flatMap(pubkey => this.eventsByAuthor.get(pubkey) || []))
|
||||
filter = omit(['authors'], filter)
|
||||
} else if (filter.since || filter.until) {
|
||||
const sinceDay = getDay(filter.since || EPOCH)
|
||||
const untilDay = getDay(filter.until || now())
|
||||
|
||||
events = uniq(
|
||||
Array.from(range(sinceDay, inc(untilDay)))
|
||||
.flatMap((day: number) => this.eventsByDay.get(day) || [])
|
||||
)
|
||||
} else {
|
||||
for (const [k, values] of Object.entries(filter)) {
|
||||
if (!k.startsWith('#') || k.length !== 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
filter = omit([k], filter)
|
||||
events = uniq(
|
||||
(values as string[]).flatMap(v => this.eventsByTag.get(`${k[1]}:${v}`) || [])
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const chunk: TrustedEvent[] = []
|
||||
for (const event of sortBy((e: TrustedEvent) => -e.created_at, events)) {
|
||||
if (filter.limit && chunk.length >= filter.limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!includeDeleted && this.isDeleted(event)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchFilter(filter, event)) {
|
||||
chunk.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
result.push(chunk)
|
||||
}
|
||||
|
||||
return uniq(flatten(result))
|
||||
}
|
||||
|
||||
publish = (event: TrustedEvent, {shouldNotify = true} = {}) => {
|
||||
if (!isTrustedEvent(event)) {
|
||||
throw new Error("Invalid event published to Repository", event)
|
||||
}
|
||||
|
||||
const address = getAddress(event)
|
||||
const duplicate = (
|
||||
this.eventsById.get(event.id) ||
|
||||
this.eventsByAddress.get(address)
|
||||
)
|
||||
|
||||
// If our duplicate is newer than the event we're adding, we're done
|
||||
if (duplicate && duplicate.created_at >= event.created_at) {
|
||||
this.deletes.set(event.id, duplicate.created_at)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Delete our duplicate
|
||||
if (duplicate) {
|
||||
this.deletes.set(duplicate.id, event.created_at)
|
||||
}
|
||||
|
||||
// Add our new event by id
|
||||
this.eventsById.set(event.id, event)
|
||||
|
||||
// Add our new event by address
|
||||
if (isReplaceable(event)) {
|
||||
this.eventsByAddress.set(address, event)
|
||||
}
|
||||
|
||||
// Save wrapper index
|
||||
if (event.wrap) {
|
||||
this.eventsByWrap.set(event.wrap.id, event)
|
||||
}
|
||||
|
||||
// Update our timestamp and author indexes
|
||||
this._updateIndex(this.eventsByDay, getDay(event.created_at), event, duplicate)
|
||||
this._updateIndex(this.eventsByAuthor, event.pubkey, event, duplicate)
|
||||
|
||||
// Keep track of deleted events to notify about
|
||||
const removed = new Set<string>()
|
||||
|
||||
// Update our tag indexes
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0].length === 1) {
|
||||
this._updateIndex(this.eventsByTag, tag.slice(0, 2).join(':'), event, duplicate)
|
||||
|
||||
// If this is a delete event, the tag value is an id or address. Track when it was
|
||||
// deleted so that replaceables can be restored.
|
||||
if (event.kind === DELETE) {
|
||||
this.deletes.set(tag[1], Math.max(event.created_at, this.deletes.get(tag[1]) || 0))
|
||||
|
||||
const deletedEvent = this.getEvent(tag[1])
|
||||
|
||||
if (deletedEvent && this.isDeleted(deletedEvent)) {
|
||||
removed.add(deletedEvent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicate) {
|
||||
removed.add(duplicate.id)
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
this.emit('update', {added: this.isDeleted(event) ? [] : [event], removed})
|
||||
}
|
||||
}
|
||||
|
||||
isDeleted = (event: TrustedEvent) => {
|
||||
const deletedAt = (
|
||||
this.deletes.get(event.id) ||
|
||||
this.deletes.get(getAddress(event)) ||
|
||||
0
|
||||
)
|
||||
|
||||
return deletedAt > event.created_at
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
_updateIndex<K>(m: Map<K, TrustedEvent[]>, k: K, e: TrustedEvent, duplicate?: TrustedEvent) {
|
||||
let a = m.get(k) || []
|
||||
|
||||
if (duplicate) {
|
||||
a = a.filter((x: TrustedEvent) => x !== duplicate)
|
||||
}
|
||||
|
||||
a.push(e)
|
||||
m.set(k, a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import {first, splitAt, identity, sortBy, uniq, shuffle, pushToMapKey} from '@welshman/lib'
|
||||
import {Tags, Tag} from './Tags'
|
||||
import type {TrustedEvent} from './Events'
|
||||
import {isReplaceable} from './Events'
|
||||
import {isShareableRelayUrl} from './Relay'
|
||||
import {getAddress, isCommunityAddress, isGroupAddress} from './Address'
|
||||
|
||||
export enum RelayMode {
|
||||
Read = "read",
|
||||
Write = "write",
|
||||
}
|
||||
|
||||
export type RouterOptions = {
|
||||
/**
|
||||
* Retrieves the user's public key.
|
||||
* @returns The user's public key as a string, or null if not available.
|
||||
*/
|
||||
getUserPubkey?: () => string | null
|
||||
|
||||
/**
|
||||
* Retrieves group relays for the specified community.
|
||||
* @param address - The address to retrieve group relays for.
|
||||
* @returns An array of group relay URLs as strings.
|
||||
*/
|
||||
getGroupRelays?: (address: string) => string[]
|
||||
|
||||
/**
|
||||
* Retrieves relays for the specified community.
|
||||
* @param address - The address to retrieve community relays for.
|
||||
* @returns An array of community relay URLs as strings.
|
||||
*/
|
||||
getCommunityRelays?: (address: string) => string[]
|
||||
|
||||
/**
|
||||
* Retrieves relays for the specified public key and mode.
|
||||
* @param pubkey - The public key to retrieve relays for.
|
||||
* @param mode - The relay mode (optional).
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getPubkeyRelays?: (pubkey: string, mode?: RelayMode) => string[]
|
||||
|
||||
/**
|
||||
* Retrieves fallback relays, for use when no other relays can be selected.
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getFallbackRelays: () => string[]
|
||||
|
||||
/**
|
||||
* Retrieves relays likely to support NIP-50 search.
|
||||
* @returns An array of relay URLs as strings.
|
||||
*/
|
||||
getSearchRelays?: () => string[]
|
||||
|
||||
/**
|
||||
* Retrieves the quality of the specified relay.
|
||||
* @param url - The URL of the relay to retrieve quality for.
|
||||
* @returns The quality of the relay as a number between 0 and 1 inclusive.
|
||||
*/
|
||||
getRelayQuality?: (url: string) => number
|
||||
|
||||
/**
|
||||
* Retrieves the redundancy setting, which is how many relays to use per selection value.
|
||||
* @returns The redundancy setting as a number.
|
||||
*/
|
||||
getRedundancy?: () => number
|
||||
|
||||
/**
|
||||
* Retrieves the limit setting, which is the maximum number of relays that should be
|
||||
* returned from getUrls and getSelections.
|
||||
* @returns The limit setting as a number.
|
||||
*/
|
||||
getLimit?: () => number
|
||||
}
|
||||
|
||||
export type ValuesByRelay = Map<string, string[]>
|
||||
|
||||
export type RelayValues = {
|
||||
relay: string
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export type ValueRelays = {
|
||||
value: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export type FallbackPolicy = (count: number, limit: number) => number
|
||||
|
||||
export class Router {
|
||||
constructor(readonly options: RouterOptions) {}
|
||||
|
||||
// Utilities derived from options
|
||||
|
||||
getPubkeySelection = (pubkey: string, mode?: RelayMode) =>
|
||||
this.selection(pubkey, this.options.getPubkeyRelays?.(pubkey, mode) || [])
|
||||
|
||||
getPubkeySelections = (pubkeys: string[], mode?: RelayMode) =>
|
||||
pubkeys.map(pubkey => this.getPubkeySelection(pubkey, mode))
|
||||
|
||||
getUserSelections = (mode?: RelayMode) =>
|
||||
this.getPubkeySelections([this.options.getUserPubkey?.()].filter(identity) as string[], mode)
|
||||
|
||||
getContextSelections = (tags: Tags) => {
|
||||
return [
|
||||
...tags.communities().mapTo(t => this.selection(t.value(), this.options.getCommunityRelays?.(t.value()) || [])).valueOf(),
|
||||
...tags.groups().mapTo(t => this.selection(t.value(), this.options.getGroupRelays?.(t.value()) || [])).valueOf(),
|
||||
]
|
||||
}
|
||||
|
||||
// Utilities for creating ValueRelays
|
||||
|
||||
selection = (value: string, relays: Iterable<string>) => ({value, relays: Array.from(relays)})
|
||||
|
||||
selections = (values: string[], relays: string[]) =>
|
||||
values.map(value => this.selection(value, relays))
|
||||
|
||||
forceValue = (value: string, selections: ValueRelays[]) =>
|
||||
selections.map(({relays}) => this.selection(value, relays))
|
||||
|
||||
// Utilities for processing hints
|
||||
|
||||
relaySelectionsFromMap = (valuesByRelay: ValuesByRelay) =>
|
||||
sortBy(
|
||||
({values}) => -values.length,
|
||||
Array.from(valuesByRelay)
|
||||
.map(([relay, values]: [string, string[]]) => ({relay, values: uniq(values)}))
|
||||
)
|
||||
|
||||
scoreRelaySelection = ({values, relay}: RelayValues) =>
|
||||
values.length * (this.options.getRelayQuality?.(relay) || 1)
|
||||
|
||||
sortRelaySelections = (relaySelections: RelayValues[]) => {
|
||||
const scores = new Map<string, number>()
|
||||
const getScore = (relayValues: RelayValues) => -(scores.get(relayValues.relay) || 0)
|
||||
|
||||
for (const relayValues of relaySelections) {
|
||||
scores.set(relayValues.relay, this.scoreRelaySelection(relayValues))
|
||||
}
|
||||
|
||||
return sortBy(getScore, relaySelections.filter(getScore))
|
||||
}
|
||||
|
||||
// Utilities for creating scenarios
|
||||
|
||||
scenario = (selections: ValueRelays[]) => new RouterScenario(this, selections)
|
||||
|
||||
merge = (scenarios: RouterScenario[]) =>
|
||||
this.scenario(scenarios.flatMap((scenario: RouterScenario) => scenario.selections))
|
||||
|
||||
product = (values: string[], relays: string[]) =>
|
||||
this.scenario(this.selections(values, relays))
|
||||
|
||||
fromRelays = (relays: string[]) => this.scenario([this.selection("", relays)])
|
||||
|
||||
// Routing scenarios
|
||||
|
||||
User = () => this.scenario(this.getUserSelections())
|
||||
|
||||
ReadRelays = () => this.scenario(this.getUserSelections(RelayMode.Read))
|
||||
|
||||
WriteRelays = () => this.scenario(this.getUserSelections(RelayMode.Write))
|
||||
|
||||
Messages = (pubkeys: string[]) =>
|
||||
this.scenario([
|
||||
...this.getUserSelections(),
|
||||
...this.getPubkeySelections(pubkeys),
|
||||
])
|
||||
|
||||
PublishMessage = (pubkey: string) =>
|
||||
this.scenario([
|
||||
...this.getUserSelections(RelayMode.Write),
|
||||
this.getPubkeySelection(pubkey, RelayMode.Read),
|
||||
]).policy(this.addMinimalFallbacks)
|
||||
|
||||
Event = (event: TrustedEvent) =>
|
||||
this.scenario(this.forceValue(event.id, [
|
||||
this.getPubkeySelection(event.pubkey, RelayMode.Write),
|
||||
...this.getContextSelections(Tags.fromEvent(event).context()),
|
||||
]))
|
||||
|
||||
EventChildren = (event: TrustedEvent) =>
|
||||
this.scenario(this.forceValue(event.id, [
|
||||
this.getPubkeySelection(event.pubkey, RelayMode.Read),
|
||||
...this.getContextSelections(Tags.fromEvent(event).context()),
|
||||
]))
|
||||
|
||||
EventAncestors = (event: TrustedEvent, type: "mentions" | "replies" | "roots") => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
const ancestors = tags.ancestors()[type]
|
||||
const pubkeys = tags.values("p").valueOf()
|
||||
const communities = tags.communities().values().valueOf()
|
||||
const groups = tags.groups().values().valueOf()
|
||||
const relays = uniq([
|
||||
...this.options.getPubkeyRelays?.(event.pubkey, RelayMode.Read) || [],
|
||||
...pubkeys.flatMap((k: string) => this.options.getPubkeyRelays?.(k, RelayMode.Write) || []),
|
||||
...communities.flatMap((a: string) => this.options.getCommunityRelays?.(a) || []),
|
||||
...groups.flatMap((a: string) => this.options.getGroupRelays?.(a) || []),
|
||||
...ancestors.relays().valueOf(),
|
||||
])
|
||||
|
||||
return this.product(ancestors.values().valueOf(), relays)
|
||||
}
|
||||
|
||||
EventMentions = (event: TrustedEvent) => this.EventAncestors(event, "mentions")
|
||||
|
||||
EventParents = (event: TrustedEvent) => this.EventAncestors(event, "replies")
|
||||
|
||||
EventRoots = (event: TrustedEvent) => this.EventAncestors(event, "roots")
|
||||
|
||||
PublishEvent = (event: TrustedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
const mentions = tags.values("p").valueOf()
|
||||
|
||||
// If we're publishing to private groups, only publish to those groups' relays
|
||||
if (tags.groups().exists()) {
|
||||
return this
|
||||
.scenario(this.getContextSelections(tags.groups()))
|
||||
.policy(this.addNoFallbacks)
|
||||
}
|
||||
|
||||
return this.scenario(this.forceValue(event.id, [
|
||||
this.getPubkeySelection(event.pubkey, RelayMode.Write),
|
||||
...this.getContextSelections(tags.context()),
|
||||
...this.getPubkeySelections(mentions, RelayMode.Read),
|
||||
]))
|
||||
}
|
||||
|
||||
FromPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(this.getPubkeySelections(pubkeys, RelayMode.Write))
|
||||
|
||||
ForPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(this.getPubkeySelections(pubkeys, RelayMode.Read))
|
||||
|
||||
WithinGroup = (address: string, relays?: string) =>
|
||||
this
|
||||
.scenario(this.getContextSelections(Tags.wrap([["a", address]])))
|
||||
.policy(this.addNoFallbacks)
|
||||
|
||||
WithinCommunity = (address: string) =>
|
||||
this.scenario(this.getContextSelections(Tags.wrap([["a", address]])))
|
||||
|
||||
WithinContext = (address: string) => {
|
||||
if (isGroupAddress(address)) {
|
||||
return this.WithinGroup(address)
|
||||
}
|
||||
|
||||
if (isCommunityAddress(address)) {
|
||||
return this.WithinCommunity(address)
|
||||
}
|
||||
|
||||
throw new Error(`Unknown context ${address}`)
|
||||
}
|
||||
|
||||
WithinMultipleContexts = (addresses: string[]) =>
|
||||
this.merge(addresses.map(this.WithinContext))
|
||||
|
||||
Search = (term: string, relays: string[] = []) =>
|
||||
this.product([term], uniq(relays.concat(this.options.getSearchRelays?.() || [])))
|
||||
|
||||
// Fallback policies
|
||||
|
||||
addNoFallbacks = (count: number, redundancy: number) => 0
|
||||
|
||||
addMinimalFallbacks = (count: number, redundancy: number) => count > 0 ? 0 : 1
|
||||
|
||||
addMaximalFallbacks = (count: number, redundancy: number) => redundancy - count
|
||||
|
||||
// Higher level utils that use hints
|
||||
|
||||
tagPubkey = (pubkey: string) =>
|
||||
Tag.from(["p", pubkey, this.FromPubkeys([pubkey]).getUrl()])
|
||||
|
||||
tagEventId = (event: TrustedEvent, mark = "") =>
|
||||
Tag.from(["e", event.id, this.Event(event).getUrl(), mark, event.pubkey])
|
||||
|
||||
tagEventAddress = (event: TrustedEvent, mark = "") =>
|
||||
Tag.from(["a", getAddress(event), this.Event(event).getUrl(), mark, event.pubkey])
|
||||
|
||||
tagEvent = (event: TrustedEvent, mark = "") => {
|
||||
const tags = [this.tagEventId(event, mark)]
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(this.tagEventAddress(event, mark))
|
||||
}
|
||||
|
||||
return new Tags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
// Router Scenario
|
||||
|
||||
export type RouterScenarioOptions = {
|
||||
redundancy?: number
|
||||
policy?: FallbackPolicy
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export class RouterScenario {
|
||||
constructor(readonly router: Router, readonly selections: ValueRelays[], readonly options: RouterScenarioOptions = {}) {}
|
||||
|
||||
clone = (options: RouterScenarioOptions) =>
|
||||
new RouterScenario(this.router, this.selections, {...this.options, ...options})
|
||||
|
||||
select = (f: (selection: string) => boolean) =>
|
||||
new RouterScenario(this.router, this.selections.filter(({value}) => f(value)), this.options)
|
||||
|
||||
redundancy = (redundancy: number) => this.clone({redundancy})
|
||||
|
||||
policy = (policy: FallbackPolicy) => this.clone({policy})
|
||||
|
||||
limit = (limit: number) => this.clone({limit})
|
||||
|
||||
getRedundancy = () => this.options.redundancy || this.router.options.getRedundancy?.() || 3
|
||||
|
||||
getPolicy = () => this.options.policy || this.router.addMaximalFallbacks
|
||||
|
||||
getLimit = () => this.options.limit || this.router.options.getLimit?.() || 10
|
||||
|
||||
getSelections = () => {
|
||||
const allValues = new Set()
|
||||
const valuesByRelay: ValuesByRelay = new Map()
|
||||
for (const {value, relays} of this.selections) {
|
||||
allValues.add(value)
|
||||
|
||||
for (const relay of relays) {
|
||||
if (isShareableRelayUrl(relay)) {
|
||||
pushToMapKey(valuesByRelay, relay, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust redundancy by limit, since if we're looking for very specific values odds
|
||||
// are we're less tolerant of failure. Add more redundancy to fill our relay limit.
|
||||
const limit = this.getLimit()
|
||||
const redundancy = this.getRedundancy()
|
||||
const adjustedRedundancy = Math.max(redundancy, redundancy * (limit / (allValues.size * redundancy)))
|
||||
|
||||
const seen = new Map<string, number>()
|
||||
const result: ValuesByRelay = new Map()
|
||||
const relaySelections = this.router.relaySelectionsFromMap(valuesByRelay)
|
||||
for (const {relay} of this.router.sortRelaySelections(relaySelections)) {
|
||||
const values = new Set<string>()
|
||||
for (const value of uniq(valuesByRelay.get(relay) || [])) {
|
||||
const timesSeen = seen.get(value) || 0
|
||||
|
||||
if (timesSeen < adjustedRedundancy) {
|
||||
seen.set(value, timesSeen + 1)
|
||||
values.add(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (values.size > 0) {
|
||||
result.set(relay, Array.from(values))
|
||||
}
|
||||
}
|
||||
|
||||
const fallbacks = shuffle(this.router.options.getFallbackRelays())
|
||||
const fallbackPolicy = this.getPolicy()
|
||||
for (const {value} of this.selections) {
|
||||
const timesSeen = seen.get(value) || 0
|
||||
const fallbacksNeeded = fallbackPolicy(timesSeen, adjustedRedundancy)
|
||||
|
||||
if (fallbacksNeeded > 0) {
|
||||
for (const relay of fallbacks.slice(0, fallbacksNeeded)) {
|
||||
pushToMapKey(result, relay, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [keep, discard] = splitAt(limit, this.router.relaySelectionsFromMap(result))
|
||||
|
||||
for (const target of keep.slice(0, redundancy)) {
|
||||
target.values = uniq(discard.concat(target).flatMap((selection: RelayValues) => selection.values))
|
||||
}
|
||||
|
||||
return keep
|
||||
}
|
||||
|
||||
getUrls = () => this.getSelections().map((selection: RelayValues) => selection.relay)
|
||||
|
||||
getUrl = () => first(this.getUrls())
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {EventTemplate} from 'nostr-tools'
|
||||
import type {OmitStatics} from '@welshman/lib'
|
||||
import {Fluent, ensurePlural} from '@welshman/lib'
|
||||
import {isShareableRelayUrl, normalizeRelayUrl} from './Relay'
|
||||
import {GROUP, COMMUNITY} from './Kinds'
|
||||
|
||||
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
|
||||
static from = (xs: Iterable<string>) => new Tag(Array.from(xs))
|
||||
|
||||
static fromId = (id: string) => new Tag(["e", id])
|
||||
|
||||
static fromIdentifier = (identifier: string) => new Tag(["d", identifier])
|
||||
|
||||
static fromTopic = (topic: string) => new Tag(["t", topic])
|
||||
|
||||
static fromPubkey = (pubkey: string) => new Tag(["p", pubkey])
|
||||
|
||||
static fromAddress = (address: string, relay = "") => new Tag(["a", address, relay])
|
||||
|
||||
key = () => this.xs[0]
|
||||
|
||||
value = () => this.xs[1]
|
||||
|
||||
entry = () => this.xs.slice(0, 2)
|
||||
|
||||
setKey = (k: string) => this.set(0, k)
|
||||
|
||||
setValue = (v: string) => this.set(1, v)
|
||||
|
||||
isAddress = (kind?: number) => this.key() === "a" && this.value()?.startsWith(`${kind}:`)
|
||||
|
||||
isGroup = () => this.isAddress(GROUP)
|
||||
|
||||
isCommunity = () => this.isAddress(COMMUNITY)
|
||||
|
||||
isContext = () => this.isAddress(GROUP) || this.isAddress(COMMUNITY)
|
||||
}
|
||||
|
||||
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
|
||||
static from = (p: Iterable<Tag>) => new Tags(Array.from(p))
|
||||
|
||||
static wrap = (p: Iterable<string[]>) => new Tags(Array.from(p).map(Tag.from))
|
||||
|
||||
static fromEvent = (event: Pick<EventTemplate, "tags">) => Tags.wrap(event.tags || [])
|
||||
|
||||
static fromEvents = (events: Pick<EventTemplate, "tags">[]) => Tags.wrap(events.flatMap(e => e.tags || []))
|
||||
|
||||
static fromIMeta = (imeta: string[]) => Tags.wrap(imeta.map((m: string) => m.split(" ")))
|
||||
|
||||
unwrap = () => this.xs.map(tag => tag.valueOf())
|
||||
|
||||
whereKey = (key: string) => this.filter(t => t.key() === key)
|
||||
|
||||
whereValue = (value: string) => this.filter(t => t.value() === value)
|
||||
|
||||
filterByKey = (keys: string[]) => this.filter(t => keys.includes(t.key()))
|
||||
|
||||
filterByValue = (values: string[]) => this.filter(t => values.includes(t.value()))
|
||||
|
||||
rejectByKey = (keys: string[]) => this.reject(t => keys.includes(t.key()))
|
||||
|
||||
rejectByValue = (values: string[]) => this.reject(t => values.includes(t.value()))
|
||||
|
||||
get = (key: string) => this.whereKey(key).first()
|
||||
|
||||
keys = () => this.mapTo(t => t.key())
|
||||
|
||||
values = (key?: string | string[]) =>
|
||||
(key ? this.filterByKey(ensurePlural(key)) : this).mapTo(t => t.value())
|
||||
|
||||
entries = () => this.mapTo(t => t.entry())
|
||||
|
||||
relays = () => this.flatMap((t: Tag) => t.valueOf().filter(isShareableRelayUrl).map(url => normalizeRelayUrl(url))).uniq()
|
||||
|
||||
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
||||
|
||||
ancestors = (x?: boolean) => {
|
||||
const tags = this.filterByKey(["a", "e", "q"]).reject(t => t.isContext())
|
||||
const mentionTags = tags.whereKey("q")
|
||||
const roots: string[][] = []
|
||||
const replies: string[][] = []
|
||||
const mentions: string[][] = []
|
||||
|
||||
const dispatchTags = (thisTags: Tags) =>
|
||||
thisTags.forEach((t: Tag, i: number) => {
|
||||
if (t.nth(3) === 'root') {
|
||||
if (tags.filter(t => t.nth(3) === "reply").count() === 0) {
|
||||
replies.push(t.valueOf())
|
||||
} else {
|
||||
roots.push(t.valueOf())
|
||||
}
|
||||
} else if (t.nth(3) === 'reply') {
|
||||
replies.push(t.valueOf())
|
||||
} else if (t.nth(3) === 'mention') {
|
||||
mentions.push(t.valueOf())
|
||||
} else if (i === thisTags.count() - 1) {
|
||||
replies.push(t.valueOf())
|
||||
} else if (i === 0) {
|
||||
roots.push(t.valueOf())
|
||||
} else {
|
||||
mentions.push(t.valueOf())
|
||||
}
|
||||
})
|
||||
|
||||
// Add different types separately so positional logic works
|
||||
dispatchTags(tags.whereKey("e"))
|
||||
dispatchTags(tags.whereKey("a").filter(t => Boolean(t.nth(3))))
|
||||
mentionTags.forEach((t: Tag) => mentions.push(t.valueOf()))
|
||||
|
||||
return {
|
||||
roots: Tags.wrap(roots),
|
||||
replies: Tags.wrap(replies),
|
||||
mentions: Tags.wrap(mentions),
|
||||
}
|
||||
}
|
||||
|
||||
roots = () => this.ancestors().roots
|
||||
|
||||
replies = () => this.ancestors().replies
|
||||
|
||||
mentions = () => this.ancestors().mentions
|
||||
|
||||
root = () => {
|
||||
const roots = this.roots()
|
||||
|
||||
return roots.get("e") || roots.get("a")
|
||||
}
|
||||
|
||||
reply = () => {
|
||||
const replies = this.replies()
|
||||
|
||||
return replies.get("e") || replies.get("a")
|
||||
}
|
||||
|
||||
parents = () => {
|
||||
const {roots, replies} = this.ancestors()
|
||||
|
||||
return replies.exists() ? replies : roots
|
||||
}
|
||||
|
||||
parent = () => {
|
||||
const parents = this.parents()
|
||||
|
||||
return parents.get("e") || parents.get("a")
|
||||
}
|
||||
|
||||
groups = () => this.whereKey("a").filter(t => t.isGroup())
|
||||
|
||||
communities = () => this.whereKey("a").filter(t => t.isCommunity())
|
||||
|
||||
context = () => this.whereKey("a").filter(t => t.isContext())
|
||||
|
||||
asObject = () => {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const t of this.xs) {
|
||||
result[t.key()] = t.value()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
imeta = (url: string) => {
|
||||
for (const tag of this.whereKey("imeta").xs) {
|
||||
const tags = Tags.fromIMeta(tag.drop(1).valueOf())
|
||||
|
||||
if (tags.get("url")?.value() === url) {
|
||||
return tags
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Generic setters
|
||||
|
||||
addTag = (...args: string[]) => this.append(Tag.from(args))
|
||||
|
||||
setTag = (k: string, ...args: string[]) => this.rejectByKey([k]).addTag(k, ...args)
|
||||
|
||||
// Context
|
||||
|
||||
addContext = (addresses: string[]) => this.concat(addresses.map(a => Tag.from(["a", a])))
|
||||
|
||||
removeContext = () => this.reject(t => t.isContext())
|
||||
|
||||
setContext = (addresses: string[]) => this.removeContext().addContext(addresses)
|
||||
|
||||
// Images
|
||||
|
||||
addImages = (imeta: Tags[]) =>
|
||||
this.concat(imeta.map(tags => Tag.from(["image", tags.get("url").value()])))
|
||||
|
||||
removeImages = () => this.rejectByKey(['image'])
|
||||
|
||||
setImages = (imeta: Tags[]) => this.removeImages().addImages(imeta)
|
||||
|
||||
// IMeta
|
||||
|
||||
addIMeta = (imeta: Tags[]) =>
|
||||
this.concat(imeta.map(tags => Tag.from(["imeta", ...tags.valueOf().map(xs => xs.join(" "))])))
|
||||
|
||||
removeIMeta = () => this.rejectByKey(['imeta'])
|
||||
|
||||
setIMeta = (imeta: Tags[]) => this.removeIMeta().addIMeta(imeta)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {hexToBech32} from '@welshman/lib'
|
||||
import type {TrustedEvent} from './Events'
|
||||
import {Tags} from "./Tags"
|
||||
|
||||
const DIVISORS = {
|
||||
m: BigInt(1e3),
|
||||
u: BigInt(1e6),
|
||||
n: BigInt(1e9),
|
||||
p: BigInt(1e12),
|
||||
}
|
||||
|
||||
const MAX_MILLISATS = BigInt("2100000000000000000")
|
||||
|
||||
const MILLISATS_PER_BTC = BigInt(1e11)
|
||||
|
||||
export const hrpToMillisat = (hrpString: string) => {
|
||||
let divisor, value
|
||||
if (hrpString.slice(-1).match(/^[munp]$/)) {
|
||||
divisor = hrpString.slice(-1)
|
||||
value = hrpString.slice(0, -1)
|
||||
} else if (hrpString.slice(-1).match(/^[^munp0-9]$/)) {
|
||||
throw new Error("Not a valid multiplier for the amount")
|
||||
} else {
|
||||
value = hrpString
|
||||
}
|
||||
|
||||
if (!value.match(/^\d+$/)) throw new Error("Not a valid human readable amount")
|
||||
|
||||
const valueBN = BigInt(value)
|
||||
|
||||
const millisatoshisBN = divisor
|
||||
? (valueBN * MILLISATS_PER_BTC) / (DIVISORS as any)[divisor]
|
||||
: valueBN * MILLISATS_PER_BTC
|
||||
|
||||
if (
|
||||
(divisor === "p" && !(valueBN % BigInt(10) === BigInt(0))) ||
|
||||
millisatoshisBN > MAX_MILLISATS
|
||||
) {
|
||||
throw new Error("Amount is outside of valid range")
|
||||
}
|
||||
|
||||
return millisatoshisBN
|
||||
}
|
||||
|
||||
export const getInvoiceAmount = (bolt11: string) => {
|
||||
const hrp = bolt11.match(/lnbc(\d+\w)/)
|
||||
const bn = hrpToMillisat(hrp![1])
|
||||
return Number(bn)
|
||||
}
|
||||
|
||||
export const getLnUrl = (address: string) => {
|
||||
if (address.startsWith("lnurl1")) {
|
||||
return address
|
||||
}
|
||||
|
||||
// If it's a regular url, just encode it
|
||||
if (address.includes("://")) {
|
||||
return hexToBech32("lnurl", address)
|
||||
}
|
||||
|
||||
// Try to parse it as a lud16 address
|
||||
if (address.includes("@")) {
|
||||
const [name, domain] = address.split("@")
|
||||
|
||||
if (domain && name) {
|
||||
return hexToBech32("lnurl", `https://${domain}/.well-known/lnurlp/${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type Zapper = {
|
||||
lnurl: string
|
||||
pubkey: string,
|
||||
callback: string
|
||||
minSendable: number
|
||||
maxSendable: number
|
||||
nostrPubkey: string
|
||||
allowsNostr: boolean
|
||||
}
|
||||
|
||||
export type Zap = {
|
||||
request: TrustedEvent
|
||||
response: TrustedEvent,
|
||||
invoiceAmount: number
|
||||
}
|
||||
|
||||
export const zapFromEvent = (response: TrustedEvent, zapper: Zapper) => {
|
||||
const responseMeta = Tags.fromEvent(response).asObject()
|
||||
|
||||
let zap: Zap
|
||||
try {
|
||||
zap = {
|
||||
response,
|
||||
invoiceAmount: getInvoiceAmount(responseMeta.bolt11),
|
||||
request: JSON.parse(responseMeta.description),
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Don't count zaps that the user sent himself
|
||||
if (zap.request.pubkey === zapper.pubkey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {amount, lnurl} = Tags.fromEvent(zap.request).asObject()
|
||||
|
||||
// Verify that the zapper actually sent the requested amount (if it was supplied)
|
||||
if (amount && parseInt(amount) !== zap.invoiceAmount) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If the sending client provided an lnurl tag, verify that too
|
||||
if (lnurl && lnurl !== zapper.lnurl) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify that the request actually came from the recipient's zapper
|
||||
if (zap.response.pubkey !== zapper.nostrPubkey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return zap
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './Address'
|
||||
export * from './Events'
|
||||
export * from './Filters'
|
||||
export * from './Kinds'
|
||||
export * from './Links'
|
||||
export * from './Relay'
|
||||
export * from './Repository'
|
||||
export * from './Router'
|
||||
export * from './Tags'
|
||||
export * from './Zaps'
|
||||
Reference in New Issue
Block a user