From 3f45cebaeee37adfaeb6ff63110a6acac2a3beea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 18 Dec 2025 17:08:11 +0100 Subject: [PATCH] feat: more flexible -e flag --- src/lib/req-parser.test.ts | 314 +++++++++++++++++++++++++++++++++++++ src/lib/req-parser.ts | 117 ++++++++++++-- src/types/man.ts | 4 +- 3 files changed, 418 insertions(+), 17 deletions(-) diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index 96eba91..8cd6ec5 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { parseReqCommand } from "./req-parser"; +import { nip19 } from "nostr-tools"; describe("parseReqCommand", () => { describe("kind flag (-k, --kind)", () => { @@ -175,6 +176,319 @@ describe("parseReqCommand", () => { }); }); + describe("event ID flag (-e) with nevent/naddr support", () => { + describe("nevent support", () => { + it("should parse nevent and populate filter.ids", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ + id: eventId, + }); + const result = parseReqCommand(["-e", nevent]); + + expect(result.filter.ids).toBeDefined(); + expect(result.filter.ids).toHaveLength(1); + expect(result.filter.ids).toEqual([eventId]); + expect(result.filter["#e"]).toBeUndefined(); + }); + + it("should extract relay hints from nevent", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ + id: eventId, + relays: ["wss://relay.damus.io"], + }); + const result = parseReqCommand(["-e", nevent]); + + expect(result.relays).toBeDefined(); + expect(result.relays).toContain("wss://relay.damus.io/"); + }); + + it("should normalize relay URLs from nevent", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ + id: eventId, + relays: ["wss://relay.damus.io"], + }); + const result = parseReqCommand(["-e", nevent]); + + result.relays?.forEach((url) => { + expect(url).toMatch(/^wss?:\/\//); + expect(url).toMatch(/\/$/); // trailing slash + }); + }); + + it("should handle nevent without relay hints", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ + id: eventId, + }); + const result = parseReqCommand(["-e", nevent]); + + expect(result.filter.ids).toHaveLength(1); + expect(result.relays).toBeUndefined(); + }); + }); + + describe("naddr support", () => { + it("should parse naddr and populate filter['#a']", () => { + const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + const result = parseReqCommand(["-e", naddr]); + + expect(result.filter["#a"]).toBeDefined(); + expect(result.filter["#a"]).toHaveLength(1); + expect(result.filter["#a"]?.[0]).toBe(`30023:${pubkey}:test-article`); + }); + + it("should extract relay hints from naddr", () => { + const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + relays: ["wss://relay.damus.io", "wss://nos.lol"], + }); + const result = parseReqCommand(["-e", naddr]); + + expect(result.relays).toBeDefined(); + expect(result.relays!.length).toBe(2); + expect(result.relays).toContain("wss://relay.damus.io/"); + expect(result.relays).toContain("wss://nos.lol/"); + }); + + it("should format coordinate correctly (kind:pubkey:identifier)", () => { + const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + const result = parseReqCommand(["-e", naddr]); + + const coordinate = result.filter["#a"]?.[0]; + expect(coordinate).toBe(`30023:${pubkey}:test-article`); + // Validate format: kind:pubkey:identifier + const parts = coordinate?.split(":"); + expect(parts).toHaveLength(3); + expect(parseInt(parts![0])).toBe(30023); + expect(parts![1]).toBe(pubkey); + expect(parts![2]).toBe("test-article"); + }); + + it("should handle naddr without relay hints", () => { + const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + const result = parseReqCommand(["-e", naddr]); + + expect(result.filter["#a"]).toHaveLength(1); + expect(result.relays).toBeUndefined(); + }); + }); + + describe("note/hex support (existing behavior)", () => { + it("should parse note and populate filter['#e']", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const note = nip19.noteEncode(eventId); + const result = parseReqCommand(["-e", note]); + + expect(result.filter["#e"]).toBeDefined(); + expect(result.filter["#e"]).toHaveLength(1); + expect(result.filter["#e"]).toContain(eventId); + expect(result.filter.ids).toBeUndefined(); + expect(result.filter["#a"]).toBeUndefined(); + }); + + it("should parse hex and populate filter['#e']", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-e", hex]); + + expect(result.filter["#e"]).toContain(hex); + expect(result.filter.ids).toBeUndefined(); + }); + }); + + describe("mixed format support", () => { + it("should handle comma-separated mix of all formats", () => { + const hex = "a".repeat(64); + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const pubkey = "b".repeat(64); + + const note = nip19.noteEncode(eventId); + const nevent = nip19.neventEncode({ id: eventId }); + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + + const result = parseReqCommand([ + "-e", + `${hex},${note},${nevent},${naddr}`, + ]); + + // hex and note should go to filter["#e"] + expect(result.filter["#e"]).toHaveLength(2); + expect(result.filter["#e"]).toContain(hex); + expect(result.filter["#e"]).toContain(eventId); + + // nevent should go to filter.ids + expect(result.filter.ids).toHaveLength(1); + expect(result.filter.ids).toContain(eventId); + + // naddr should go to filter["#a"] + expect(result.filter["#a"]).toHaveLength(1); + expect(result.filter["#a"]?.[0]).toBe(`30023:${pubkey}:test-article`); + }); + + it("should deduplicate within each filter field", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent1 = nip19.neventEncode({ id: eventId }); + const nevent2 = nip19.neventEncode({ id: eventId, relays: ["wss://relay.damus.io"] }); + + const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]); + + // Both nevent decode to same event ID, should deduplicate + expect(result.filter.ids).toHaveLength(1); + }); + + it("should collect relay hints from mixed formats", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const pubkey = "b".repeat(64); + + const nevent = nip19.neventEncode({ + id: eventId, + relays: ["wss://relay.damus.io"], + }); + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + relays: ["wss://nos.lol"], + }); + + const result = parseReqCommand(["-e", `${nevent},${naddr}`]); + + expect(result.relays).toBeDefined(); + expect(result.relays!.length).toBe(2); + expect(result.relays).toContain("wss://relay.damus.io/"); + expect(result.relays).toContain("wss://nos.lol/"); + }); + + it("should handle multiple nevents with different relay hints", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent1 = nip19.neventEncode({ + id: eventId, + relays: ["wss://relay.damus.io"], + }); + const hex = "b".repeat(64); + + const result = parseReqCommand(["-e", `${nevent1},${hex}`]); + + // nevent goes to filter.ids + expect(result.filter.ids).toHaveLength(1); + // hex goes to filter["#e"] + expect(result.filter["#e"]).toContain(hex); + // relays extracted from nevent + expect(result.relays).toBeDefined(); + expect(result.relays).toContain("wss://relay.damus.io/"); + }); + }); + + describe("error handling", () => { + it("should ignore invalid bech32", () => { + const result = parseReqCommand(["-e", "nevent1invalid"]); + + expect(result.filter.ids).toBeUndefined(); + expect(result.filter["#e"]).toBeUndefined(); + expect(result.filter["#a"]).toBeUndefined(); + }); + + it("should ignore invalid naddr", () => { + const result = parseReqCommand(["-e", "naddr1invalid"]); + + expect(result.filter["#a"]).toBeUndefined(); + }); + + it("should skip empty values in comma-separated list", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-e", `${hex},,`]); + + expect(result.filter["#e"]).toEqual([hex]); + }); + + it("should continue parsing after encountering invalid values", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand([ + "-e", + `${hex1},invalid_bech32,${hex2}`, + ]); + + expect(result.filter["#e"]).toEqual([hex1, hex2]); + }); + }); + + describe("integration with other flags", () => { + it("should work with kind filter", () => { + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ id: eventId }); + const result = parseReqCommand(["-k", "1", "-e", nevent]); + + expect(result.filter.kinds).toEqual([1]); + expect(result.filter.ids).toHaveLength(1); + }); + + it("should work with explicit relays", () => { + const pubkey = "b".repeat(64); + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + const result = parseReqCommand([ + "-e", + naddr, + "wss://relay.example.com", + ]); + + expect(result.filter["#a"]).toHaveLength(1); + expect(result.relays).toContain("wss://relay.example.com/"); + }); + + it("should work with author and time filters", () => { + const hex = "c".repeat(64); + const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ id: eventId }); + const result = parseReqCommand([ + "-k", + "1", + "-a", + hex, + "-e", + nevent, + "--since", + "24h", + "-l", + "50", + ]); + + expect(result.filter.kinds).toEqual([1]); + expect(result.filter.authors).toEqual([hex]); + expect(result.filter.ids).toHaveLength(1); + expect(result.filter.since).toBeDefined(); + expect(result.filter.limit).toBe(50); + }); + }); + }); + describe("pubkey tag flag (-p)", () => { it("should parse hex pubkey for #p tag", () => { const hex = "a".repeat(64); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 9ed43d2..8a3e9d2 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -45,10 +45,10 @@ function parseCommaSeparated( /** * Parse REQ command arguments into a Nostr filter * Supports: - * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag) + * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (note/nevent/naddr/hex), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag) * - Time: --since, --until * - Search: --search - * - Relays: wss://relay.com or relay.com (auto-adds wss://), nprofile relay hints are automatically extracted + * - Relays: wss://relay.com or relay.com (auto-adds wss://), relay hints from nprofile/nevent/naddr are automatically extracted * - Options: --close-on-eose (close stream after EOSE, default: stream stays open) */ export function parseReqCommand(args: string[]): ParsedReqCommand { @@ -61,7 +61,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { // Use sets for deduplication during accumulation const kinds = new Set(); const authors = new Set(); - const eventIds = new Set(); + const ids = new Set(); // For filter.ids (direct event lookup) + const eventIds = new Set(); // For filter["#e"] (tag-based event lookup) + const aTags = new Set(); // For filter["#a"] (coordinate-based lookup) const pTags = new Set(); const pTagsUppercase = new Set(); const tTags = new Set(); @@ -165,16 +167,38 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { } case "-e": { - // Support comma-separated event IDs: -e id1,id2,id3 + // Support comma-separated event identifiers: -e note1...,nevent1...,naddr1...,hex if (!nextArg) { i++; break; } - const addedAny = parseCommaSeparated( - nextArg, - parseNoteOrHex, - eventIds, - ); + + let addedAny = false; + const values = nextArg.split(",").map((v) => v.trim()); + + for (const val of values) { + if (!val) continue; + + const parsed = parseEventIdentifier(val); + if (parsed) { + // Route to appropriate filter field based on type + if (parsed.type === "direct-event") { + ids.add(parsed.value); + } else if (parsed.type === "direct-address") { + aTags.add(parsed.value); + } else if (parsed.type === "tag-event") { + eventIds.add(parsed.value); + } + + // Collect relay hints + if (parsed.relays) { + relays.push(...parsed.relays); + } + + addedAny = true; + } + } + i += addedAny ? 2 : 1; break; } @@ -367,7 +391,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { // Convert accumulated sets to filter arrays (with deduplication) if (kinds.size > 0) filter.kinds = Array.from(kinds); if (authors.size > 0) filter.authors = Array.from(authors); + if (ids.size > 0) filter.ids = Array.from(ids); if (eventIds.size > 0) filter["#e"] = Array.from(eventIds); + if (aTags.size > 0) filter["#a"] = Array.from(aTags); if (pTags.size > 0) filter["#p"] = Array.from(pTags); if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase); if (tTags.size > 0) filter["#t"] = Array.from(tTags); @@ -487,27 +513,88 @@ function parseNpubOrHex(value: string): { return { pubkey: null }; } +interface ParsedEventIdentifier { + type: "direct-event" | "direct-address" | "tag-event"; + value: string; + relays?: string[]; +} + /** - * Parse note1 or hex event ID + * Parse event identifier - supports note, nevent, naddr, and hex event ID */ -function parseNoteOrHex(value: string): string | null { +function parseEventIdentifier(value: string): ParsedEventIdentifier | null { if (!value) return null; - // Try to decode note1 + // nevent: direct event lookup with relay hints + if (value.startsWith("nevent")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "nevent") { + return { + type: "direct-event", + value: decoded.data.id, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + // Not valid nevent, continue + } + } + + // naddr: coordinate-based lookup with relay hints + if (value.startsWith("naddr")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "naddr") { + const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + return { + type: "direct-address", + value: coordinate, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + // Not valid naddr, continue + } + } + + // note1: tag-based filtering (existing behavior) if (value.startsWith("note")) { try { const decoded = nip19.decode(value); if (decoded.type === "note") { - return decoded.data; + return { + type: "tag-event", + value: decoded.data, + }; } } catch { // Not valid note, continue } } - // Check if it's hex event ID + // Hex: tag-based filtering (existing behavior) if (isValidHexEventId(value)) { - return normalizeHex(value); + return { + type: "tag-event", + value: normalizeHex(value), + }; } return null; diff --git a/src/types/man.ts b/src/types/man.ts index 45602df..b49fe45 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -164,9 +164,9 @@ export const manPages: Record = { description: "Maximum number of events to return", }, { - flag: "-e ", + flag: "-e ", description: - "Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3", + "Filter by event ID or coordinate. Supports note1 (bare event ID), nevent1 (event with relay hints), naddr1 (addressable event coordinate), or raw hex. Comma-separated values supported: -e note1...,nevent1...,naddr1...", }, { flag: "-p ",