From 94e19a5760e5618383c900fda399c54febdf6bf2 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 26 Feb 2024 16:37:04 -0800 Subject: [PATCH] re work tags again --- README.md | 22 ++--- src/connect/ConnectionMeta.ts | 2 +- src/connect/Subscription.ts | 5 +- src/main.ts | 25 +++--- src/nostr/Tag.ts | 19 ----- src/nostr/Tags.ts | 148 ---------------------------------- src/nostr/kinds.ts | 8 -- src/util/Address.ts | 82 +++++++++++++++++++ src/util/Events.ts | 53 ++++++++++++ src/util/Filters.ts | 44 ++++++++++ src/util/Fluent.ts | 52 ++++++------ src/util/Kinds.ts | 81 +++++++++++++++++++ src/util/Relays.ts | 34 ++++++++ src/{nostr => util}/Router.ts | 22 ++--- src/util/Tags.ts | 98 ++++++++++++++++++++++ src/util/Tools.ts | 31 +++++++ src/util/misc.ts | 21 ----- src/util/nostr.ts | 129 ----------------------------- 18 files changed, 491 insertions(+), 385 deletions(-) delete mode 100644 src/nostr/Tag.ts delete mode 100644 src/nostr/Tags.ts delete mode 100644 src/nostr/kinds.ts create mode 100644 src/util/Address.ts create mode 100644 src/util/Events.ts create mode 100644 src/util/Filters.ts create mode 100644 src/util/Kinds.ts create mode 100644 src/util/Relays.ts rename src/{nostr => util}/Router.ts (91%) create mode 100644 src/util/Tags.ts create mode 100644 src/util/Tools.ts delete mode 100644 src/util/misc.ts delete mode 100644 src/util/nostr.ts diff --git a/README.md b/README.md index 32334f2..f523bec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/connect/ConnectionMeta.ts b/src/connect/ConnectionMeta.ts index bc870d7..2b3dac0 100644 --- a/src/connect/ConnectionMeta.ts +++ b/src/connect/ConnectionMeta.ts @@ -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 diff --git a/src/connect/Subscription.ts b/src/connect/Subscription.ts index 1d69bce..0ce7798 100644 --- a/src/connect/Subscription.ts +++ b/src/connect/Subscription.ts @@ -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 diff --git a/src/main.ts b/src/main.ts index f2dfde7..4982b9e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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" diff --git a/src/nostr/Tag.ts b/src/nostr/Tag.ts deleted file mode 100644 index a813112..0000000 --- a/src/nostr/Tag.ts +++ /dev/null @@ -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 as OmitStatics, 'from'>) { - static from(parts: Iterable) { - 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)) -} diff --git a/src/nostr/Tags.ts b/src/nostr/Tags.ts deleted file mode 100644 index cd54513..0000000 --- a/src/nostr/Tags.ts +++ /dev/null @@ -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 as OmitStatics, 'from'>) { - static from(p: Iterable) { - 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 | Tags | Tag | Tag[] | string[] | Iterable - -export const coerceToTags = (x: CoercibleToTags) => { - const xs = isIterable(x) ? Array.from(x as Iterable) : [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)) diff --git a/src/nostr/kinds.ts b/src/nostr/kinds.ts deleted file mode 100644 index 32a2ddc..0000000 --- a/src/nostr/kinds.ts +++ /dev/null @@ -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) diff --git a/src/util/Address.ts b/src/util/Address.ts new file mode 100644 index 0000000..f35a3f4 --- /dev/null +++ b/src/util/Address.ts @@ -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], + }) +} diff --git a/src/util/Events.ts b/src/util/Events.ts new file mode 100644 index 0000000..5a1117a --- /dev/null +++ b/src/util/Events.ts @@ -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 + +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({ + 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) + diff --git a/src/util/Filters.ts b/src/util/Filters.ts new file mode 100644 index 0000000..6d097b7 --- /dev/null +++ b/src/util/Filters.ts @@ -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 +} diff --git a/src/util/Fluent.ts b/src/util/Fluent.ts index 70da117..1aa5026 100644 --- a/src/util/Fluent.ts +++ b/src/util/Fluent.ts @@ -1,49 +1,53 @@ -import {last} from './misc' +import {last} from './Tools' export class Fluent { - constructor(readonly parts: T[]) {} + constructor(readonly xs: T[]) {} - static from(parts: Iterable) { - return new Fluent(Array.from(parts)) + static from(xs: Iterable) { + return new Fluent(Array.from(xs)) } - clone>(this: K, parts: T[]): K { - return new (this.constructor as { new (parts: T[]): K })(parts) + clone>(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 = (f: (t: T) => U) => new Fluent(Array.from(this.parts).map(f)) + map = (f: (t: T) => T) => this.clone(this.xs.map(f)) + + mapTo = (f: (t: T) => U) => new Fluent(this.xs.map(f)) + + flatMap = (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]) } diff --git a/src/util/Kinds.ts b/src/util/Kinds.ts new file mode 100644 index 0000000..da406f1 --- /dev/null +++ b/src/util/Kinds.ts @@ -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 diff --git a/src/util/Relays.ts b/src/util/Relays.ts new file mode 100644 index 0000000..6e180ad --- /dev/null +++ b/src/util/Relays.ts @@ -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 +} diff --git a/src/nostr/Router.ts b/src/util/Router.ts similarity index 91% rename from src/nostr/Router.ts rename to src/util/Router.ts index 599c5fe..2d451b2 100644 --- a/src/nostr/Router.ts +++ b/src/util/Router.ts @@ -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)] diff --git a/src/util/Tags.ts b/src/util/Tags.ts new file mode 100644 index 0000000..5203b77 --- /dev/null +++ b/src/util/Tags.ts @@ -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 as OmitAllStatics>) { + static from(xs: Iterable) { + 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 as OmitAllStatics>) { + static from(p: Iterable) { + return new Tags(Array.from(p).map(Tag.from)) + } + + static fromEvent(event: EventTemplate) { + return Tags.from(event.tags) + } + + static fromEvents(events: Iterable) { + 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) +} diff --git a/src/util/Tools.ts b/src/util/Tools.ts new file mode 100644 index 0000000..d32852e --- /dev/null +++ b/src/util/Tools.ts @@ -0,0 +1,31 @@ +export const now = () => Math.round(Date.now() / 1000) + +export const nth = (i: number) => (xs: T[]) => xs[i] + +export const first = (xs: T[]) => xs[0] + +export const last = (xs: T[]) => xs[xs.length - 1] + +export const identity = (x: T) => x + +export const between = (low: number, high: number, n: number) => n > low && n < high + +export const flatten = (xs: T[]) => xs.flatMap(identity) + +export const uniq = (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: 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}` diff --git a/src/util/misc.ts b/src/util/misc.ts deleted file mode 100644 index 93aefa2..0000000 --- a/src/util/misc.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const now = () => Math.round(Date.now() / 1000) - -export const nth = (i: number) => (xs: T[]) => xs[i] - -export const first = (xs: T[]) => xs[0] - -export const last = (xs: T[]) => xs[xs.length - 1] - -export const identity = (x: T) => x - -export const flatten = (xs: T[]) => xs.flatMap(identity) - -export const uniq = (xs: T[]) => Array.from(new Set(xs)) - -// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253 -export type OmitStatics = - T extends {new(...args: infer A): infer R} ? - {new(...args: A): R}&Omit : - Omit; - -export const isIterable = (x: any) => Symbol.iterator in Object(x) diff --git a/src/util/nostr.ts b/src/util/nostr.ts deleted file mode 100644 index 626f236..0000000 --- a/src/util/nostr.ts +++ /dev/null @@ -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({ - 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 -}