re work tags again

This commit is contained in:
Jon Staab
2024-02-26 16:37:04 -08:00
parent a78e72310d
commit 94e19a5760
18 changed files with 491 additions and 385 deletions
+11 -11
View File
@@ -7,35 +7,35 @@ A nostr toolkit focused on creating highly a configurable client system. What pa
Some general-purpose utilities used in paravel.
- `Deferred` is just a promise with `resolve` and `reject` methods.
- `Queue` is an implementation of an asynchronous queue.
- `LRUCache` is an implementation of an LRU cache.
- `Emitter` extends EventEmitter to support `emitter.on('*', ...)`.
## /nostr
Some nostr-specific utilities.
- `Fluent` is a wrapper around arrays with chained methods that modify and copy the underlying array.
- `Kinds` contains kind constants and related utility functions.
- `LRUCache` is an implementation of an LRU cache.
- `Queue` is an implementation of an asynchronous queue.
- `Relays` contains utilities related to relays.
- `Router` is a utility for selecting relay urls based on user preferences and protocol hints.
- `Tags` and `Tag` extend `Fluent` to provide a convenient way to access and modify tags.
- `Tools` is a collection of general-purpose utility functions.
## /connect
Utilities having to do with connection management and nostr messages.
- `Socket` is a wrapper around isomorphic-ws that handles json parsing/serialization.
- `Connection` is a wrapper for `Socket` with send and receive queues, and a `ConnectionMeta` instance.
- `ConnectionMeta` tracks stats for a given `Connection`.
- `Connection` is a wrapper for `Socket` with send and receive queues, and a `ConnectionMeta` instance.
- `Executor` implements common nostr flows on `target`
- `Pool` is a thin wrapper around `Map` for use with `Relay`s.
- `Socket` is a wrapper around isomorphic-ws that handles json parsing/serialization.
- `Subscription` is a higher-level utility for making requests against multiple nostr relays.
## /connect/target
Executor targets extend `Emitter`, and have a `send` method, a `cleanup` method, and a `connections` getter. They are intended to be passed to an `Executor` for use.
- `Multi` allows you to compose multiple targets together.
- `Plex` takes an array of urls and a `Connection` and sends and receives wrapped nostr messages over that connection.
- `Relay` takes a `Connection` and provides listeners for different verbs.
- `Relays` takes an array of `Connection`s and provides listeners for different verbs, merging all events into a single stream.
- `Plex` takes an array of urls and a `Connection` and sends and receives wrapped nostr messages over that connection.
- `Multi` allows you to compose multiple targets together.
# Example
+1 -1
View File
@@ -1,6 +1,6 @@
import type {Event, Filter} from 'nostr-tools'
import type {Connection} from './Connection'
import type {Message} from './util/Socket'
import type {Message} from './Socket'
export type PublishMeta = {
sent: number
+3 -2
View File
@@ -1,8 +1,9 @@
import EventEmitter from "events"
import type {Event} from 'nostr-tools'
import type {Executor} from "./Executor"
import type {Filter} from '../util/nostr'
import {matchFilters, hasValidSignature} from "../util/nostr"
import type {Filter} from '../util/Filters'
import {matchFilters} from "../util/Filters"
import {hasValidSignature} from "../util/Events"
export type SubscriptionOpts = {
executor: Executor
+14 -11
View File
@@ -1,19 +1,22 @@
export * from "./util/misc"
export * from "./util/nostr"
export * from "./util/LRUCache"
export * from "./util/Deferred"
export * from "./util/Emitter"
export * from "./util/Queue"
export * from "./util/Fluent"
export * from "./nostr/Tag"
export * from "./nostr/Tags"
export * from "./connect/Socket"
export * from "./connect/Connection"
export * from "./connect/ConnectionMeta"
export * from "./connect/Executor"
export * from "./connect/Pool"
export * from "./connect/Socket"
export * from "./connect/Subscription"
export * from "./connect/target/Multi"
export * from "./connect/target/Plex"
export * from "./connect/target/Relay"
export * from "./connect/target/Relays"
export * from "./connect/target/Multi"
export * from "./util/Deferred"
export * from "./util/Emitter"
export * from "./util/Events"
export * from "./util/Filters"
export * from "./util/Fluent"
export * from "./util/Kinds"
export * from "./util/LRUCache"
export * from "./util/Queue"
export * from "./util/Relays"
export * from "./util/Router"
export * from "./util/Tags"
export * from "./util/Tools"
-19
View File
@@ -1,19 +0,0 @@
import type {OmitStatics} from '../util/misc'
import {last} from '../util/misc'
import {Fluent} from '../util/Fluent'
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
static from(parts: Iterable<string>) {
return new Tag(Array.from(parts))
}
key = () => this.parts[0]
value = () => this.parts[1]
mark = () => last(this.parts.slice(2))
entry = () => this.parts.slice(0, 2)
append = (s: string) => Tag.from(this.parts.concat(s))
}
-148
View File
@@ -1,148 +0,0 @@
import type {Event} from 'nostr-tools'
import {Fluent} from '../util/Fluent'
import type {OmitStatics} from '../util/misc'
import {isIterable, uniq} from '../util/misc'
import {isShareableRelay} from '../util/nostr'
import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './kinds'
import {Tag} from './Tag'
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
static from(p: Iterable<Tag | string[]>) {
return new Tags(Array.from(p).map(Tag.from))
}
static fromEvent(event: Event) {
return Tags.from(event.tags)
}
static fromEvents(events: Event[]) {
return Tags.from(events.flatMap((e: Event) => e?.tags))
}
// General purpose filters
whereKey = (key: string) => this.filter(t => t.key() === key)
whereValue = (value: string) => this.filter(t => t.value() === value)
whereMark = (mark: string) => this.filter(t => t.mark() === mark)
// General purpose methods that return a list of values
keys = () => new Fluent(this.parts.map(t => t.key()))
values = () => new Fluent(this.parts.map(t => t.value()))
marks = () => new Fluent(this.parts.map(t => t.mark()))
entries = () => new Fluent(this.parts.map(t => t.entry()))
}
export type CoercibleToTags = Event | Iterable<Event> | Tags | Tag | Tag[] | string[] | Iterable<string[]>
export const coerceToTags = (x: CoercibleToTags) => {
const xs = isIterable(x) ? Array.from(x as Iterable<any>) : [x]
if (xs.length === 0) {
return new Tags(xs)
}
if (xs[0] instanceof Event) {
return Tags.fromEvents(xs)
}
if (xs[0] instanceof Array) {
return Tags.from(xs)
}
if (typeof xs[0] === 'string') {
return Tags.from([xs])
}
throw new Error('Received invalid value to coerceToTags: ${x}')
}
export const fromTags = (tags: Tags) => Array.from(tags).map(tag => Array.from(tag))
export const getRelays = (x: CoercibleToTags) =>
uniq(Array.from(coerceToTags(x)).flatMap((t: Tag) => Array.from(t)).filter(isShareableRelay))
export const getTopics = (x: CoercibleToTags) =>
Array.from(coerceToTags(x).whereKey("t").values()).map((t: string) => t.replace(/^#/, ""))
export const getPubkeys = (x: CoercibleToTags) =>
Array.from(coerceToTags(x).whereKey("p").values())
export const getUrls = (x: CoercibleToTags) =>
Array.from(coerceToTags(x).whereKey("r").values())
export const getAncestorsLegacy = (x: CoercibleToTags) => {
// Legacy only supports e tags. Normalize their length to 3
const eTags = Tags.from(
coerceToTags(x).whereKey("e").map((t: Tag) => {
while (t.count() < 3) {
t.append("")
}
return t.slice(0, 3)
})
)
return {
roots: eTags.slice(0, 1),
replies: eTags.slice(-1),
mentions: eTags.slice(1, -1),
}
}
type GetAncestorsReturn = {
roots: Tags
replies: Tags
mentions: Tags
}
export const getAncestors = (x: CoercibleToTags, key?: string): GetAncestorsReturn => {
const tags = coerceToTags(x)
// If we have a mark, we're not using the legacy format
if (!tags.some((t: Tag) => t.count() === 4 && ["reply", "root", "mention"].includes(t.mark()))) {
return getAncestorsLegacy(tags)
}
const eTags = tags.whereKey("e")
const aTags = tags.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value()))
const allTags = coerceToTags([...eTags, ...aTags])
return {
roots: allTags.whereMark('root').take(3),
replies: allTags.whereMark('reply').take(3),
mentions: allTags.whereMark('mention').take(3),
}
}
export const getRoots = (x: CoercibleToTags, key?: string) =>
getAncestors(x, key).roots
export const getReplies = (x: CoercibleToTags, key?: string) =>
getAncestors(x, key).replies
export const getGroups = (x: CoercibleToTags) =>
coerceToTags(x).whereKey("a").values().filter(isGroupAddress)
export const getCommunities = (x: CoercibleToTags) =>
coerceToTags(x).whereKey("a").values().filter(isCommunityAddress)
export const getCommunitiesAndGroups = (x: CoercibleToTags) =>
coerceToTags(x).whereKey("a").values().filter(isCommunityOrGroupAddress)
export const getRoot = (x: CoercibleToTags, key?: string) =>
getRoots(x, key).values().first()
export const getReply = (x: CoercibleToTags, key?: string) =>
getReplies(x, key).values().first()
export const getRootHints = (x: CoercibleToTags, key?: string) =>
getRelays(getRoots(x, key))
export const getReplyHints = (x: CoercibleToTags, key?: string) =>
getRelays(getReplies(x, key))
-8
View File
@@ -1,8 +0,0 @@
export const GROUP = 35834
export const COMMUNITY = 34550
export const isGroupAddress = (a: string) => a.startsWith(`${GROUP}:`)
export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY}:`)
export const isCommunityOrGroupAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a)
+82
View File
@@ -0,0 +1,82 @@
import type {UnsignedEvent} from 'nostr-tools'
import {nip19} from 'nostr-tools'
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
import {Tags} from './Tags'
export const isGroupAddress = (a: string) => a.startsWith(`${GROUP_DEFINITION}:`)
export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY_DEFINITION}:`)
export const isCommunityOrGroupAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a)
export class Address {
readonly kind: number
constructor(
kind: string | number,
readonly pubkey: string,
readonly identifier: string,
readonly relays: string[],
) {
this.kind = parseInt(kind as string)
this.identifier = identifier || ""
}
static fromEvent = (e: UnsignedEvent, relays: string[] = []) =>
new Address(e.kind, e.pubkey, Tags.fromEvent(e).whereKey("d").values().first(), relays)
static fromTagValue = (a: string, relays: string[] = []) => {
const [kind, pubkey, identifier] = a.split(":")
return new Address(kind, pubkey, identifier, relays)
}
static fromTag = (tag: string[], relays: string[] = []) => {
const [a, hint] = tag.slice(1)
if (hint) {
relays = relays.concat(hint)
}
return this.fromTagValue(a, 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)
}
asTagValue = () => [this.kind, this.pubkey, this.identifier].join(":")
asTag = (mark?: string) => {
const tag = ["a", this.asTagValue(), this.relays[0] || ""]
if (mark) {
tag.push(mark)
}
return tag
}
asNaddr = () => nip19.naddrEncode(this)
asFilter = () => ({
kinds: [this.kind],
authors: [this.pubkey],
"#d": [this.identifier],
})
}
+53
View File
@@ -0,0 +1,53 @@
import type {Event, EventTemplate} from 'nostr-tools'
import {verifyEvent, getEventHash} from 'nostr-tools'
import {cached} from "./LRUCache"
import {now} from './Tools'
import {Address} from './Address'
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
export type Rumor = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey' | 'id'>
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 hasValidSignature = cached<string, boolean, [Event]>({
maxSize: 10000,
getKey: ([e]: [Event]) => {
try {
return [getEventHash(e), e.sig].join(":")
} catch (err) {
return 'invalid'
}
},
getValue: ([e]: [Event]) => {
try {
return verifyEvent(e)
} catch (err) {
return false
}
},
})
export const getAddress = (e: Rumor) => Address.fromEvent(e).asTagValue()
export const getIdOrAddress = (e: Rumor) => isReplaceable(e) ? getAddress(e) : e.id
export const getIdAndAddress = (e: Rumor) => isReplaceable(e) ? [e.id, getAddress(e)] : [e.id]
export const getIdOrAddressTag = (e: Rumor, hint: string) =>
isReplaceable(e) ? ["a", getAddress(e), hint] : ["e", e.id, hint]
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)
+44
View File
@@ -0,0 +1,44 @@
import type {Event} from 'nostr-tools'
import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
export type Filter = {
ids?: string[]
kinds?: number[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
export const matchFilter = (filter: Filter, event: Event) => {
if (!nostrToolsMatchFilter(filter, 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 = (filters: Filter[], event: Event) => {
for (const filter of filters) {
if (matchFilter(filter, event)) {
return true
}
}
return false
}
+28 -24
View File
@@ -1,49 +1,53 @@
import {last} from './misc'
import {last} from './Tools'
export class Fluent<T> {
constructor(readonly parts: T[]) {}
constructor(readonly xs: T[]) {}
static from<T>(parts: Iterable<T>) {
return new Fluent<T>(Array.from(parts))
static from<T>(xs: Iterable<T>) {
return new Fluent<T>(Array.from(xs))
}
clone<K extends Fluent<T>>(this: K, parts: T[]): K {
return new (this.constructor as { new (parts: T[]): K })(parts)
clone<K extends Fluent<T>>(this: K, xs: T[]): K {
return new (this.constructor as { new (xs: T[]): K })(xs)
}
*[Symbol.iterator]() {
for (const x of this.parts) {
yield x
}
}
valueOf = () => this.xs
first = () => Array.from(this.parts)[0]
first = () => this.xs[0]
nth = (i: number) => Array.from(this.parts)[i]
nth = (i: number) => this.xs[i]
last = () => last(Array.from(this.parts))
last = () => last(this.xs)
count = () => Array.from(this.parts).length
count = () => this.xs.length
exists = () => Array.from(this.parts).length > 0
exists = () => this.xs.length > 0
every = (f: (t: T) => boolean) => Array.from(this.parts).every(f)
every = (f: (t: T) => boolean) => this.xs.every(f)
some = (f: (t: T) => boolean) => Array.from(this.parts).some(f)
some = (f: (t: T) => boolean) => this.xs.some(f)
find = (f: (t: T) => boolean) => Array.from(this.parts).find(f)
find = (f: (t: T) => boolean) => this.xs.find(f)
uniq = () => this.clone(Array.from(new Set(this.parts)))
uniq = () => this.clone(Array.from(new Set(this.xs)))
slice = (a: number, b?: number) => this.clone(Array.from(this.parts).slice(a, b))
slice = (a: number, b?: number) => this.clone(this.xs.slice(a, b))
take = (n: number) => this.slice(0, n)
drop = (n: number) => this.slice(n)
filter = (f: (t: T) => boolean) => this.clone(Array.from(this.parts).filter(f))
filter = (f: (t: T) => boolean) => this.clone(this.xs.filter(f))
reject = (f: (t: T) => boolean) => this.clone(Array.from(this.parts).filter(t => !f(t)))
reject = (f: (t: T) => boolean) => this.clone(this.xs.filter(t => !f(t)))
map = <U>(f: (t: T) => U) => new Fluent(Array.from(this.parts).map(f))
map = (f: (t: T) => T) => this.clone(this.xs.map(f))
mapTo = <U>(f: (t: T) => U) => new Fluent(this.xs.map(f))
flatMap = <U>(f: (t: T) => U[]) => new Fluent(this.xs.flatMap(f))
concat = (xs: T[]) => this.clone(this.xs.concat(xs))
append = (x: T) => this.concat([x])
}
+81
View File
@@ -0,0 +1,81 @@
import {between} from './Tools'
export const isEphemeralKind = (kind: number) => between(19999, 29999, kind)
export const isPlainReplaceableKind = (kind: number) => between(9999, 20000, kind)
export const isParameterizedReplaceableKind = (kind: number) => between(29999, 40000, kind)
export const isReplaceableKind = (kind: number) => isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind)
export const PROFILE = 0
export const NOTE = 1
export const RELAY = 2
export const DM = 4
export const EVENT_DELETION = 5
export const REPOST = 6
export const REACTION = 7
export const BADGE_AWARD = 8
export const GENERIC_REPOST = 16
export const CHANNEL_CREATION = 40
export const CHANNEL_METADATA = 41
export const CHANNEL_MESSAGE = 42
export const CHANNEL_HIDE_MESSAGE = 43
export const CHANNEL_MUTE_USER = 44
export const OPEN_TIMESTAMP = 1040
export const GIFT_WRAP = 1059
export const FILE_METADATA = 1063
export const LIVE_CHAT_MESSAGE = 1311
export const PROBLEM_TRACKER = 1971
export const REPORT = 1984
export const LABEL = 1985
export const COMMUNITY_POST_APPROVAL = 4550
export const JOB_REQUEST = 5999
export const JOB_RESULT = 6999
export const JOB_FEEDBACK = 7000
export const ZAP_GOAL = 9041
export const ZAP_REQUEST = 9734
export const ZAP_RESPONSE = 9735
export const HIGHLIGHT = 9802
export const USER_LIST_MUTES = 10000
export const USER_LIST_PINS = 10001
export const USER_LIST_RELAYS = 10002
export const USER_LIST_BOOKMARKS = 10003
export const USER_LIST_COMMUNITIES = 10004
export const USER_LIST_PUBLIC_CHATS = 10005
export const USER_LIST_BLOCKED_RELAYS = 10006
export const USER_LIST_SEARCH_RELAYS = 10007
export const USER_LIST_INTERESTS = 10015
export const USER_LIST_EMOJIS = 10030
export const LIGHTNING_PUB_RPC = 21000
export const CLIENT_AUTH = 22242
export const NWC_INFO = 13194
export const NWC_REQUEST = 23194
export const NWC_RESPONSE = 23195
export const NOSTR_CONNECT = 24133
export const HTTP_AUTH = 27235
export const LIST_FOLLOWS = 3
export const LIST_PEOPLE = 30000
export const LIST_GENERIC = 30001
export const LIST_RELAYS = 30002
export const LIST_BOOKMARKS = 30003
export const LIST_CURATIONS = 30004
export const PROFILE_BADGES = 30008
export const BADGE_DEFINITION = 30009
export const LIST_EMOJIS = 30030
export const LIST_INTERESTS = 30015
export const LONG_FORM_ARTICLE = 30023
export const LONG_FORM_ARTICLE_DRAFT = 30024
export const APPLICATION = 30078
export const LIVE_EVENT = 30311
export const USER_STATUSES = 30315
export const CLASSIFIED_LISTING = 30402
export const DRAFT_CLASSIFIED_LISTING = 30403
export const CALENDAR = 31924
export const CALENDAR_EVENT_DATE = 31922
export const CALENDAR_EVENT_TIME = 31923
export const CALENDAR_EVENT_RSVP = 31925
export const HANDLER_RECOMMENDATION = 31989
export const HANDLER_INFORMATION = 31990
export const COMMUNITY_DEFINITION = 34550
export const GROUP_DEFINITION = 35834
+34
View File
@@ -0,0 +1,34 @@
import normalizeUrl from "normalize-url"
import {stripProtocol} from './Tools'
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 raw ip addresses
!url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) &&
// Skip nostr.wine's virtual relays
!url.slice(6).match(/\/npub/)
)
export const normalizeRelayUrl = (url: string) => {
// Use our library to normalize
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
// Strip the protocol since only wss works
url = stripProtocol(url)
// Urls without pathnames are supposed to have a trailing slash
if (!url.includes("/")) {
url += "/"
}
return "wss://" + url
}
+11 -11
View File
@@ -1,6 +1,6 @@
import type {Event} from 'nostr-tools'
import {Tags, fromTags, getPubkeys, getGroups, getCommunities, getCommunitiesAndGroups, getReplyHints, getRootHints} from './Tags'
import {nth, first} from '../util/misc'
import {Tags} from './Tags'
import {nth, first} from '../util/Tools'
export type RouterOptions = {
getUserPubkey: () => string | null
@@ -28,7 +28,7 @@ export class Router {
getPubkeyRelayTags = (pubkey: string, mode?: string) => {
const tags = this.options.getPubkeyRelayTags(pubkey)
return mode ? fromTags(Tags.from(tags).whereMark(mode)) : tags
return mode ? Tags.from(tags).whereMark(mode).valueOf() : tags
}
getPubkeyRelayUrls = (pubkey: string, mode?: string) =>
@@ -47,14 +47,14 @@ export class Router {
}
getEventGroupOrCommunityRelayUrlGroups = (event: Event, otherGroups: string[][]) => {
const groupAddresses = getGroups(event)
const groupAddresses = Tags.fromEvent(event).groups().valueOf()
if (groupAddresses.count() > 0) {
return Array.from(groupAddresses.map(this.getGroupRelayUrls))
if (groupAddresses.length > 0) {
return groupAddresses.map(this.getGroupRelayUrls)
}
return [
...getCommunities(event).map(this.getCommunityRelayUrls),
...Tags.fromEvent(event).communities().valueOf().map(this.getCommunityRelayUrls),
...otherGroups,
]
}
@@ -138,7 +138,7 @@ export class Router {
fallbackPolicy: useMaximalFallbacks("read"),
getGroups: () =>
this.getEventGroupOrCommunityRelayUrlGroups(event, [
getReplyHints(event),
Tags.fromEvent(event).replies().relays().valueOf(),
this.getPubkeyRelayUrls(event.pubkey, "read"),
]),
})
@@ -147,7 +147,7 @@ export class Router {
fallbackPolicy: useMaximalFallbacks("read"),
getGroups: () =>
this.getEventGroupOrCommunityRelayUrlGroups(event, [
getRootHints(event),
Tags.fromEvent(event).roots().relays().valueOf(),
this.getPubkeyRelayUrls(event.pubkey, "read"),
]),
})
@@ -157,7 +157,7 @@ export class Router {
getGroups: () =>
this.getEventGroupOrCommunityRelayUrlGroups(event, [
this.getPubkeyRelayUrls(event.pubkey, "write"),
...getPubkeys(event).map(pubkey => this.getPubkeyRelayUrls(pubkey, "read")),
...Tags.fromEvent(event).whereKey("p").values().valueOf().map((pk: string) => this.getPubkeyRelayUrls(pk, "read")),
]),
})
@@ -198,7 +198,7 @@ export class RouterScenario {
if (urls.length < limit) {
const {mode, getLimit} = this.options.fallbackPolicy
const fallbackRelayTags = this.router.options.getFallbackRelayTags()
const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values()
const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values().valueOf()
const fallbackLimit = getLimit(limit, urls)
return [...urls, ...fallbackUrls.slice(0, fallbackLimit)]
+98
View File
@@ -0,0 +1,98 @@
import type {EventTemplate} from 'nostr-tools'
import {Fluent} from './Fluent'
import type {OmitAllStatics} from './Tools'
import {last} from './Tools'
import {isShareableRelayUrl} from './Relays'
import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './Address'
export class Tag extends (Fluent<string> as OmitAllStatics<typeof Fluent<string>>) {
static from(xs: Iterable<string>) {
return new Tag(Array.from(xs))
}
valueOf = () => this.xs
key = () => this.xs[0]
value = () => this.xs[1]
mark = () => last(this.xs.slice(2))
entry = () => this.xs.slice(0, 2)
}
export class Tags extends (Fluent<Tag> as OmitAllStatics<typeof Fluent<Tag>>) {
static from(p: Iterable<string[]>) {
return new Tags(Array.from(p).map(Tag.from))
}
static fromEvent(event: EventTemplate) {
return Tags.from(event.tags)
}
static fromEvents(events: Iterable<EventTemplate>) {
return Tags.from(Array.from(events).flatMap((e: EventTemplate) => e.tags))
}
// @ts-ignore
valueOf = () => this.xs.map(tag => tag.valueOf())
whereKey = (key: string) => this.filter(t => t.key() === key)
whereValue = (value: string) => this.filter(t => t.value() === value)
whereMark = (mark: string) => this.filter(t => t.mark() === mark)
keys = () => this.mapTo(t => t.key())
values = () => this.mapTo(t => t.value())
marks = () => this.mapTo(t => t.mark())
entries = () => this.mapTo(t => t.entry())
relays = () => this.flatMap((t: Tag) => t.valueOf().filter(isShareableRelayUrl)).uniq()
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
getAncestorsLegacy(this: Tags) {
// Legacy only supports e tags. Normalize their length to 3
const eTags =
this
.whereKey("e")
.map((t: Tag) => t.concat([""]).slice(0, 3))
return {
roots: eTags.slice(0, 1),
replies: eTags.slice(-1),
mentions: eTags.slice(1, -1),
}
}
getAncestors = (key?: string) => {
// If we have a mark, we're not using the legacy format
if (!this.some((t: Tag) => t.count() === 4 && ["reply", "root", "mention"].includes(t.mark()))) {
return this.getAncestorsLegacy()
}
const eTags = this.whereKey("e")
const aTags = this.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value()))
const allTags = eTags.concat(aTags.xs)
return {
roots: allTags.whereMark('root').map((t: Tag) => t.take(3)),
replies: allTags.whereMark('reply').map((t: Tag) => t.take(3)),
mentions: allTags.whereMark('mention').map((t: Tag) => t.take(3)),
}
}
roots = (key?: string) => this.getAncestors(key).roots
replies = (key?: string) => this.getAncestors(key).replies
groups = () => this.whereKey("a").values().filter(isGroupAddress)
communities = () => this.whereKey("a").values().filter(isCommunityAddress)
communitiesAndGroups = () => this.whereKey("a").values().filter(isCommunityOrGroupAddress)
}
+31
View File
@@ -0,0 +1,31 @@
export const now = () => Math.round(Date.now() / 1000)
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
export const first = <T>(xs: T[]) => xs[0]
export const last = <T>(xs: T[]) => xs[xs.length - 1]
export const identity = <T>(x: T) => x
export const between = (low: number, high: number, n: number) => n > low && n < high
export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
export const isIterable = (x: any) => Symbol.iterator in Object(x)
export const toIterable = (x: any) => isIterable(x) ? x : [x]
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
export type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
T extends {new(...args: infer A): infer R, prototype: infer P} ?
{new(...args: A): R, prototype: P} :
never;
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
export const toNostrURI = (s: string) => `nostr:${s}`
-21
View File
@@ -1,21 +0,0 @@
export const now = () => Math.round(Date.now() / 1000)
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
export const first = <T>(xs: T[]) => xs[0]
export const last = <T>(xs: T[]) => xs[xs.length - 1]
export const identity = <T>(x: T) => x
export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
export type OmitStatics<T, S extends string> =
T extends {new(...args: infer A): infer R} ?
{new(...args: A): R}&Omit<T, S> :
Omit<T, S>;
export const isIterable = (x: any) => Symbol.iterator in Object(x)
-129
View File
@@ -1,129 +0,0 @@
import type {Event} from 'nostr-tools'
import normalizeUrl from "normalize-url"
import {verifyEvent, getEventHash, matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
import {cached} from "./LRUCache"
import {now} from './misc'
// ===========================================================================
// Relays
export const stripProto = (url: string) => url.replace(/.*:\/\//, "")
export const isShareableRelay = (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.match(/\s/) &&
// It shouldn't have any url-encoded whitespace
!url.match(/%/) &&
// Is it secure
url.match(/^wss:\/\/.+/) &&
// Don't match stuff with a port number
!url.slice(6).match(/:\d+/) &&
// 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/)
)
export const normalizeRelayUrl = (url: string) => {
// Use our library to normalize
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
// Strip the protocol since only wss works
url = stripProto(url)
// Urls without pathnames are supposed to have a trailing slash
if (!url.includes("/")) {
url += "/"
}
return "wss://" + url
}
// ===========================================================================
// Nostr URIs
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
export const toNostrURI = (s: string) => `nostr:${s}`
// ===========================================================================
// Events
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 hasValidSignature = cached<string, boolean, [Event]>({
maxSize: 10000,
getKey: ([e]: [Event]) => {
try {
return [getEventHash(e), e.sig].join(":")
} catch (err) {
return 'invalid'
}
},
getValue: ([e]: [Event]) => {
try {
return verifyEvent(e)
} catch (err) {
return false
}
},
})
// ===========================================================================
// Filters
export type Filter = {
ids?: string[]
kinds?: number[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
export const matchFilter = (filter: Filter, event: Event) => {
if (!nostrToolsMatchFilter(filter, 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 = (filters: Filter[], event: Event) => {
for (const filter of filters) {
if (matchFilter(filter, event)) {
return true
}
}
return false
}