re work tags again
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user