import { bech32ToHex, hexToBech32, isHex, removeUndefined, unwrap, Bech32Regex, sanitizeRelayUrl, appendDedupe, } from "@snort/shared"; import { decodeTLV, encodeTLV, EventExt, EventKind, NostrEvent, NostrPrefix, TaggedNostrEvent, TLVEntryType } from "."; import { findTag } from "./utils"; /** * An object which can be stored in a nostr event as a tag */ export interface ToNostrEventTag { toEventTag(): Array | undefined; equals(other: ToNostrEventTag): boolean; } export class NostrHashtagLink implements ToNostrEventTag { constructor(readonly tag: string) {} equals(other: ToNostrEventTag): boolean { const otherTag = other.toEventTag(); return otherTag?.at(0) === "t" && otherTag?.at(1) === this.tag; } toEventTag() { return ["t", this.tag]; } } export class UnknownTag implements ToNostrEventTag { constructor(readonly value: Array) {} equals(other: ToNostrEventTag): boolean { const otherTag = other.toEventTag(); return otherTag?.at(0) === this.value.at(0) && otherTag?.at(1) === this.value.at(1); } toEventTag(): string[] | undefined { return this.value; } } export class NostrLink implements ToNostrEventTag { constructor( readonly type: NostrPrefix, readonly id: string, readonly kind?: number, readonly author?: string, readonly relays?: Array, readonly marker?: string, ) { if (type !== NostrPrefix.Address && !isHex(id)) { throw new Error(`ID must be hex: ${JSON.stringify(id)}`); } } encode(type?: NostrPrefix): string { try { // cant encode 'naddr' to 'note'/'nevent' because 'id' is not hex let newType = this.type === NostrPrefix.Address ? this.type : type ?? this.type; if (newType === NostrPrefix.Note || newType === NostrPrefix.PrivateKey || newType === NostrPrefix.PublicKey) { return hexToBech32(newType, this.id); } else { return encodeTLV(newType, this.id, this.relays, this.kind, this.author); } } catch (e) { console.error("Invalid data", this, e); throw e; } } get tagKey() { if (this.type === NostrPrefix.Address) { return `${this.kind}:${this.author}:${this.id}`; } return this.id; } /** * Create an event tag for this link */ toEventTag(marker?: string) { const suffix: Array = []; if (this.relays && this.relays.length > 0) { suffix.push(this.relays[0]); } if (marker) { if (suffix[0] === undefined) { suffix.push(""); // empty relay hint } suffix.push(marker); } if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) { return ["p", this.id, ...suffix]; } else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) { if (this.author) { if (suffix[0] === undefined) { suffix.push(""); // empty relay hint } if (suffix[1] === undefined) { suffix.push(""); // empty marker } suffix.push(this.author); } return ["e", this.id, ...suffix]; } else if (this.type === NostrPrefix.Address) { return ["a", `${this.kind}:${this.author}:${this.id}`, ...suffix]; } } /** * Create an event tag from this link as per NIP-22 (no marker position) */ toEventTagNip22(root?: boolean) { // emulate root flag by root marker root ??= this.marker === "root"; const suffix: Array = []; if (this.relays && this.relays.length > 0) { suffix.push(this.relays[0]); } if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) { return [root ? "P" : "p", this.id, ...suffix]; } else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) { if (this.author) { if (suffix[0] === undefined) { suffix.push(""); // empty relay hint } suffix.push(this.author); } return [root ? "E" : "e", this.id, ...suffix]; } else if (this.type === NostrPrefix.Address) { return [root ? "A" : "a", `${this.kind}:${this.author}:${this.id}`, ...suffix]; } } matchesEvent(ev: NostrEvent) { if (this.type === NostrPrefix.Address) { const dTag = findTag(ev, "d"); if (dTag && dTag === this.id && unwrap(this.author) === ev.pubkey && unwrap(this.kind) === ev.kind) { return true; } } else if (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note) { const ifSetCheck = (a: T | undefined, b: T) => { return !Boolean(a) || a === b; }; return ( (EventExt.isReplaceable(ev.kind) || ifSetCheck(this.id, ev.id)) && ifSetCheck(this.author, ev.pubkey) && ifSetCheck(this.kind, ev.kind) ); } return false; } /** * Is the supplied event a reply to this link */ isReplyToThis(ev: NostrEvent) { const NonNip10Kinds = [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]; if (NonNip10Kinds.includes(ev.kind)) { const links = removeUndefined(ev.tags.filter(a => a[0] === "e" || a[0] === "a").map(a => NostrLink.fromTag(a))); if (links.length === 0) return false; return links.some(a => a.equals(this)); } else { const thread = EventExt.extractThread(ev); if (!thread) return false; // non-thread events are not replies if (!thread.root) return false; // must have root marker or positional e/a tag in position 0 if ( thread.root.key === "e" && thread.root.value === this.id && (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note) ) { return true; } if (thread.root.key === "a" && this.type === NostrPrefix.Address) { const [kind, author, dTag] = unwrap(thread.root.value).split(":"); if (Number(kind) === this.kind && author === this.author && dTag === this.id) { return true; } } } return false; } /** * Does the supplied event contain a tag matching this link */ referencesThis(ev: NostrEvent) { for (const t of ev.tags) { if (t[0] === "e" && t[1] === this.id && (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note)) { return true; } if (t[0] === "a" && this.type === NostrPrefix.Address) { const [kind, author, dTag] = t[1].split(":"); if (Number(kind) === this.kind && author === this.author && dTag === this.id) { return true; } } if ( t[0] === "p" && (this.type === NostrPrefix.Profile || this.type === NostrPrefix.PublicKey) && this.id === t[1] ) { return true; } } return false; } equals(other: NostrLink) { return other.tagKey === this.tagKey; } static fromTag(tag: Array, author?: string, kind?: number) { const relays = tag.length > 2 ? [tag[2]] : undefined; switch (tag[0]) { case "E": { return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[3], relays, "root"); } case "e": { return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[4], relays, tag[3]); } case "p": { return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays); } case "A": { const [kind, author, dTag] = tag[1].split(":"); if (!isHex(author)) { throw new Error(`Invalid author in A tag: ${tag[1]}`); } return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, "root"); } case "a": { const [kind, author, dTag] = tag[1].split(":"); if (!isHex(author)) { throw new Error(`Invalid author in a tag: ${tag[1]}`); } return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, tag[3]); } } throw new Error("Unknown tag!"); } static tryFromTag(tag: Array, author?: string, kind?: number) { try { return NostrLink.fromTag(tag, author, kind); } catch (e) { // ignored } } static fromTags(tags: ReadonlyArray>) { return removeUndefined( tags.map(a => { try { return NostrLink.fromTag(a); } catch { // ignored, cant be mapped } }), ); } /** * Parse all tags even if they are unknown */ static fromAllTags(tags: ReadonlyArray>): Array { return removeUndefined( tags.map(a => { try { return NostrLink.fromTag(a); } catch { return new UnknownTag(a); } }), ); } /** * Create an event link from an existing nostr event */ static fromEvent(ev: TaggedNostrEvent | NostrEvent) { let relays = "relays" in ev ? ev.relays : undefined; const eventRelays = removeUndefined( ev.tags .filter(a => a[0] === "relays" || a[0] === "relay" || (a[0] === "r" && ev.kind == EventKind.Relays)) .flatMap(a => a.slice(1).map(b => sanitizeRelayUrl(b))), ); relays = appendDedupe(relays, eventRelays); if (ev.kind >= 30_000 && ev.kind < 40_000) { const dTag = unwrap(findTag(ev, "d")); return new NostrLink(NostrPrefix.Address, dTag, ev.kind, ev.pubkey, relays); } return new NostrLink(NostrPrefix.Event, ev.id, ev.kind, ev.pubkey, relays); } static profile(pk: string, relays?: Array) { return new NostrLink(NostrPrefix.Profile, pk, undefined, undefined, relays); } static publicKey(pk: string, relays?: Array) { return new NostrLink(NostrPrefix.PublicKey, pk, undefined, undefined, relays); } } export function validateNostrLink(link: string): boolean { try { const parsedLink = parseNostrLink(link); if (!parsedLink) { return false; } if (parsedLink.type === NostrPrefix.PublicKey || parsedLink.type === NostrPrefix.Note) { return parsedLink.id.length === 64; } return true; } catch { return false; } } export function tryParseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink | undefined { try { return parseNostrLink(link, prefixHint); } catch { return undefined; } } export function trimNostrLink(link: string) { let entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link; // trim any non-bech32 chars entity = entity.match(Bech32Regex)?.[0] ?? entity; return entity; } export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink { const entity = trimNostrLink(link); const isPrefix = (prefix: NostrPrefix) => { return entity.startsWith(prefix); }; if (isPrefix(NostrPrefix.PublicKey)) { const id = bech32ToHex(entity); if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id"); return new NostrLink(NostrPrefix.PublicKey, id); } else if (isPrefix(NostrPrefix.Note)) { const id = bech32ToHex(entity); if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id"); return new NostrLink(NostrPrefix.Note, id); } else if ( isPrefix(NostrPrefix.Profile) || isPrefix(NostrPrefix.Event) || isPrefix(NostrPrefix.Address) || isPrefix(NostrPrefix.Req) ) { const decoded = decodeTLV(entity); const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string; const relays = decoded.filter(a => a.type === TLVEntryType.Relay).map(a => a.value as string); const author = decoded.find(a => a.type === TLVEntryType.Author)?.value as string; const kind = decoded.find(a => a.type === TLVEntryType.Kind)?.value as number; if (isPrefix(NostrPrefix.Profile)) { if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id"); return new NostrLink(NostrPrefix.Profile, id, kind, author, relays); } else if (isPrefix(NostrPrefix.Event)) { if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id"); return new NostrLink(NostrPrefix.Event, id, kind, author, relays); } else if (isPrefix(NostrPrefix.Address)) { return new NostrLink(NostrPrefix.Address, id, kind, author, relays); } else if (isPrefix(NostrPrefix.Req)) { return new NostrLink(NostrPrefix.Req, id); } } else if (prefixHint) { return new NostrLink(prefixHint, link); } throw new Error("Invalid nostr link"); }