@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit
277 lines (235 loc) • 9.03 kB
text/typescript
import { nip19 } from "nostr-tools";
import type { NDK } from "../ndk/index.js";
import { NDKRelay } from "../relay/index.js";
import { NDKRelaySet } from "../relay/sets/index.js";
import type { EventPointer } from "../user/index.js";
import type { NDKFilter, NDKSubscription } from "./index.js";
/**
* Don't generate subscription Ids longer than this amount of characters
* (plus 4-chars random number)
*/
const MAX_SUBID_LENGTH = 20;
/**
* Checks if a subscription is fully guaranteed to have been filled.
*
* This is useful to determine if a cache hit fully satisfies a subscription.
*
* @param subscription
* @returns
*/
export function queryFullyFilled(subscription: NDKSubscription): boolean {
if (filterIncludesIds(subscription.filter)) {
if (resultHasAllRequestedIds(subscription)) {
return true;
}
}
return false;
}
/**
* Compares whether a filter includes another filter.
* @param filter1 Filter to compare from
* @param filter2 Filter to compare to
* @example
* const filter1 = { authors: ["a", "b"] };
* const filter2 = { authors: ["a", "b", "c"] };
* compareFilter(filter1, filter2); // true
*
* const filter1 = { authors: ["a", "b"] };
* const filter2 = { authors: ["a", "c"] };
* compareFilter(filter1, filter2); // false
* @returns
*/
export function compareFilter(filter1: NDKFilter, filter2: NDKFilter) {
// Make sure the filters have the same number of keys
if (Object.keys(filter1).length !== Object.keys(filter2).length) return false;
for (const [key, value] of Object.entries(filter1)) {
const valuesInFilter2 = filter2[key as keyof NDKFilter] as string[];
if (!valuesInFilter2) return false;
if (Array.isArray(value) && Array.isArray(valuesInFilter2)) {
const v: string[] = value as string[];
// make sure all values in the filter are in the other filter
for (const valueInFilter2 of valuesInFilter2) {
const val: string = valueInFilter2 as string;
if (!v.includes(val)) {
return false;
}
}
} else {
if (valuesInFilter2 !== value) return false;
}
}
return true;
}
function filterIncludesIds(filter: NDKFilter): boolean {
return !!filter.ids;
}
function resultHasAllRequestedIds(subscription: NDKSubscription): boolean {
const ids = subscription.filter.ids;
return !!ids && ids.length === subscription.eventFirstSeen.size;
}
/**
* Generates a subscription ID based on the subscriptions and filter.
*
* When some of the subscriptions specify a subId, those are used,
* joining them with a comma.
*
* If none of the subscriptions specify a subId, a subId is generated
* by joining all the filter keys, and expanding the kinds with the requested kinds.
*/
export function generateSubId(subscriptions: NDKSubscription[], filters: NDKFilter[]): string {
const subIds = subscriptions.map((sub) => sub.subId).filter(Boolean);
const subIdParts: string[] = [];
const filterNonKindKeys = new Set<string>();
const filterKinds = new Set<number>();
if (subIds.length > 0) {
subIdParts.push(Array.from(new Set(subIds)).join(","));
} else {
for (const filter of filters) {
for (const key of Object.keys(filter)) {
if (key === "kinds") {
filter.kinds?.forEach((k) => filterKinds.add(k));
} else {
filterNonKindKeys.add(key);
}
}
}
if (filterKinds.size > 0) {
subIdParts.push(`kinds:${Array.from(filterKinds).join(",")}`);
}
if (filterNonKindKeys.size > 0) {
subIdParts.push(Array.from(filterNonKindKeys).join(","));
}
}
let subId = subIdParts.join("-");
if (subId.length > MAX_SUBID_LENGTH) subId = subId.substring(0, MAX_SUBID_LENGTH);
// Add the random string to the resulting subId
subId += `-${Math.floor(Math.random() * 999).toString()}`;
return subId;
}
/**
* Creates a valid nostr filter to REQ events that are tagging a NIP-19 bech32
* @param id Bech32 of the event
* @example
* const bech32 = "nevent1qgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0spzamhxue69uhhyetvv9ujuurjd9kkzmpwdejhgtcqype6ycavy2e9zpx9mzeuekaahgw96ken0mzkcmgz40ljccwyrn88gxv2ewr"
* const filter = filterForEventsTaggingId(bech32);
* // filter => { "#e": [<id>] }
*
* @example
* const bech32 = "naddr1qvzqqqr4gupzpjjwt0eqm6as279wf079c0j42jysp2t4s37u8pg5w2dfyktxgkntqqxnzde38yen2desxqmn2d3332u3ff";
* const filter = filterForEventsTaggingId(bech32);
* // filter => { "#a": ["30023:ca4e5bf20debb0578ae4bfc5c3e55548900a975847dc38514729a92596645a6b:1719357007561"]}
*/
export function filterForEventsTaggingId(id: string): NDKFilter | undefined {
try {
const decoded = nip19.decode(id);
switch (decoded.type) {
case "naddr":
return {
"#a": [`${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`],
};
case "nevent":
return { "#e": [decoded.data.id] };
case "note":
return { "#e": [decoded.data] };
case "nprofile":
return { "#p": [decoded.data.pubkey] };
case "npub":
return { "#p": [decoded.data] };
}
} catch {}
}
/**
* Creates a valid nostr filter from a bech32 encoding along with a relay set (if one is present in the encoding).
* @param id Bech32 of the event
* @param ndk
* @returns
* @example
* const bech32 = "nevent1qgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0spzamhxue69uhhyetvv9ujuurjd9kkzmpwdejhgtcqype6ycavy2e9zpx9mzeuekaahgw96ken0mzkcmgz40ljccwyrn88gxv2ewr";
* const { filter, relaySet } = filterAndRelaySetFromBech32(bech32, ndk);
* // filter => { ids: [...], authors: [...] }
*/
export function filterAndRelaySetFromBech32(bech32: string, ndk: NDK): { filter: NDKFilter; relaySet?: NDKRelaySet } {
const filter = filterFromId(bech32);
const relays = relaysFromBech32(bech32, ndk);
if (relays.length === 0) return { filter };
return {
filter,
relaySet: new NDKRelaySet(new Set(relays), ndk),
};
}
/**
* Creates a valid nostr filter from an event id or a NIP-19 bech32.
*
* @example
* const bech32 = "nevent1qgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0spzamhxue69uhhyetvv9ujuurjd9kkzmpwdejhgtcqype6ycavy2e9zpx9mzeuekaahgw96ken0mzkcmgz40ljccwyrn88gxv2ewr"
* const filter = filterFromBech32(bech32);
* // filter => { ids: [...], authors: [...] }
*/
export function filterFromId(id: string): NDKFilter {
let decoded;
if (id.match(NIP33_A_REGEX)) {
const [kind, pubkey, identifier] = id.split(":");
const filter: NDKFilter = {
authors: [pubkey],
kinds: [Number.parseInt(kind)],
};
if (identifier) {
filter["#d"] = [identifier];
}
return filter;
}
if (id.match(BECH32_REGEX)) {
try {
decoded = nip19.decode(id);
switch (decoded.type) {
case "nevent": {
const filter: NDKFilter = { ids: [decoded.data.id] };
if (decoded.data.author) filter.authors = [decoded.data.author];
if (decoded.data.kind) filter.kinds = [decoded.data.kind];
return filter;
}
case "note":
return { ids: [decoded.data] };
case "naddr": {
const filter: NDKFilter = {
authors: [decoded.data.pubkey],
kinds: [decoded.data.kind],
};
if (decoded.data.identifier) filter["#d"] = [decoded.data.identifier];
return filter;
}
}
} catch (e) {
console.error("Error decoding", id, e);
// Empty
}
}
return { ids: [id] };
}
export function isNip33AValue(value: string): boolean {
return value.match(NIP33_A_REGEX) !== null;
}
/**
* Matches an `a` tag of a NIP-33 (kind:pubkey:[identifier])
*/
export const NIP33_A_REGEX = /^(\d+):([0-9A-Fa-f]+)(?::(.*))?$/;
export const BECH32_REGEX = /^n(event|ote|profile|pub|addr)1[\d\w]+$/;
/**
* Returns the specified relays from a NIP-19 bech32.
*
* @param bech32 The NIP-19 bech32.
*/
export function relaysFromBech32(bech32: string, ndk: NDK): NDKRelay[] {
try {
const decoded = nip19.decode(bech32);
if (["naddr", "nevent"].includes(decoded?.type)) {
const data = decoded.data as unknown as EventPointer;
if (data?.relays) {
return data.relays.map((r: string) => new NDKRelay(r, ndk.relayAuthDefaultPolicy, ndk));
}
}
} catch (_e) {
/* empty */
}
return [];
}