import { bech32ToHex, hexToBech32, unwrap } from "@snort/shared"; import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV, NostrEvent, TaggedNostrEvent, EventExt, Tag } from "."; import { findTag } from "./utils"; export class NostrLink { constructor( readonly type: NostrPrefix, readonly id: string, readonly kind?: number, readonly author?: string, readonly relays?: Array, ) {} encode(): string { if (this.type === NostrPrefix.Note || this.type === NostrPrefix.PrivateKey || this.type === NostrPrefix.PublicKey) { return hexToBech32(this.type, this.id); } else { return encodeTLV(this.type, this.id, this.relays, this.kind, this.author); } } toEventTag() { const relayEntry = this.relays ? [this.relays[0]] : []; if (this.type === NostrPrefix.PublicKey) { return ["p", this.id]; } else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) { return ["e", this.id, ...relayEntry]; } else if (this.type === NostrPrefix.Address) { return ["a", `${this.kind}:${this.author}:${this.id}`, ...relayEntry]; } } 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) { return this.id === ev.id; } return false; } /** * Is the supplied event a reply to this link */ isReplyToThis(ev: NostrEvent) { 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) { if (other.type === this.type && this.type === NostrPrefix.Address) { } } static fromThreadTag(tag: Tag) { const relay = tag.relay ? [tag.relay] : undefined; switch (tag.key) { case "e": { return new NostrLink(NostrPrefix.Event, unwrap(tag.value), undefined, undefined, relay); } case "p": { return new NostrLink(NostrPrefix.Profile, unwrap(tag.value), undefined, undefined, relay); } case "a": { const [kind, author, dTag] = unwrap(tag.value).split(":"); return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relay); } } throw new Error(`Unknown tag kind ${tag.key}`); } static fromTag(tag: Array) { const relays = tag.length > 2 ? [tag[2]] : undefined; switch (tag[0]) { case "e": { return new NostrLink(NostrPrefix.Event, tag[1], undefined, undefined, relays); } case "p": { return new NostrLink(NostrPrefix.Profile, tag[1], undefined, undefined, relays); } case "a": { const [kind, author, dTag] = tag[1].split(":"); return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays); } } throw new Error(`Unknown tag kind ${tag[0]}`); } static fromEvent(ev: TaggedNostrEvent | NostrEvent) { const relays = "relays" in ev ? ev.relays : undefined; 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); } } 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 parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink { const entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : 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)) { 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 (prefixHint) { return new NostrLink(prefixHint, link); } throw new Error("Invalid nostr link"); }