diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index b1babf0..670924e 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -216,8 +216,8 @@ describe("parseReqCommand", () => { }); describe("event ID flag (-e) with nevent/naddr support", () => { - describe("nevent support", () => { - it("should parse nevent and populate filter.ids", () => { + describe("nevent support (tag filtering)", () => { + it("should parse nevent and populate filter['#e'] (tag filtering)", () => { const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; const nevent = nip19.neventEncode({ @@ -225,10 +225,10 @@ describe("parseReqCommand", () => { }); 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(); + expect(result.filter["#e"]).toBeDefined(); + expect(result.filter["#e"]).toHaveLength(1); + expect(result.filter["#e"]).toEqual([eventId]); + expect(result.filter.ids).toBeUndefined(); }); it("should extract relay hints from nevent", () => { @@ -267,7 +267,7 @@ describe("parseReqCommand", () => { }); const result = parseReqCommand(["-e", nevent]); - expect(result.filter.ids).toHaveLength(1); + expect(result.filter["#e"]).toHaveLength(1); expect(result.relays).toBeUndefined(); }); }); @@ -364,7 +364,7 @@ describe("parseReqCommand", () => { }); describe("mixed format support", () => { - it("should handle comma-separated mix of all formats", () => { + it("should handle comma-separated mix of all formats (all to tags)", () => { const hex = "a".repeat(64); const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; @@ -383,14 +383,13 @@ describe("parseReqCommand", () => { `${hex},${note},${nevent},${naddr}`, ]); - // hex and note should go to filter["#e"] + // hex, note, and nevent all go to filter["#e"] (deduplicated: eventId appears twice) 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); + // No direct ID lookup for -e flag + expect(result.filter.ids).toBeUndefined(); // naddr should go to filter["#a"] expect(result.filter["#a"]).toHaveLength(1); @@ -408,8 +407,9 @@ describe("parseReqCommand", () => { const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]); - // Both nevent decode to same event ID, should deduplicate - expect(result.filter.ids).toHaveLength(1); + // Both nevent decode to same event ID, should deduplicate in #e + expect(result.filter["#e"]).toHaveLength(1); + expect(result.filter.ids).toBeUndefined(); }); it("should collect relay hints from mixed formats", () => { @@ -447,10 +447,12 @@ describe("parseReqCommand", () => { const result = parseReqCommand(["-e", `${nevent1},${hex}`]); - // nevent goes to filter.ids - expect(result.filter.ids).toHaveLength(1); - // hex goes to filter["#e"] + // Both nevent and hex go to filter["#e"] + expect(result.filter["#e"]).toHaveLength(2); + expect(result.filter["#e"]).toContain(eventId); expect(result.filter["#e"]).toContain(hex); + // No direct ID lookup for -e flag + expect(result.filter.ids).toBeUndefined(); // relays extracted from nevent expect(result.relays).toBeDefined(); expect(result.relays).toContain("wss://relay.damus.io/"); @@ -499,7 +501,8 @@ describe("parseReqCommand", () => { const result = parseReqCommand(["-k", "1", "-e", nevent]); expect(result.filter.kinds).toEqual([1]); - expect(result.filter.ids).toHaveLength(1); + expect(result.filter["#e"]).toHaveLength(1); + expect(result.filter.ids).toBeUndefined(); }); it("should work with explicit relays", () => { @@ -539,13 +542,199 @@ describe("parseReqCommand", () => { expect(result.filter.kinds).toEqual([1]); expect(result.filter.authors).toEqual([hex]); - expect(result.filter.ids).toHaveLength(1); + expect(result.filter["#e"]).toHaveLength(1); + expect(result.filter.ids).toBeUndefined(); expect(result.filter.since).toBeDefined(); expect(result.filter.limit).toBe(50); }); }); }); + describe("direct ID lookup flag (-i, --id)", () => { + describe("basic parsing", () => { + it("should parse hex event ID to filter.ids", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-i", hex]); + expect(result.filter.ids).toEqual([hex]); + }); + + it("should parse note to filter.ids", () => { + const eventId = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const note = nip19.noteEncode(eventId); + const result = parseReqCommand(["-i", note]); + expect(result.filter.ids).toEqual([eventId]); + }); + + it("should parse nevent to filter.ids", () => { + const eventId = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ id: eventId }); + const result = parseReqCommand(["-i", nevent]); + expect(result.filter.ids).toEqual([eventId]); + }); + + it("should handle --id long form", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["--id", hex]); + expect(result.filter.ids).toEqual([hex]); + }); + }); + + describe("comma-separated values", () => { + it("should parse comma-separated hex IDs", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-i", `${hex1},${hex2}`]); + expect(result.filter.ids).toEqual([hex1, hex2]); + }); + + it("should parse comma-separated mixed formats", () => { + const hex = "a".repeat(64); + const eventId = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const note = nip19.noteEncode(eventId); + const nevent = nip19.neventEncode({ id: eventId }); + + const result = parseReqCommand(["-i", `${hex},${note},${nevent}`]); + + // hex is unique, note and nevent decode to same eventId (deduplicated) + expect(result.filter.ids).toHaveLength(2); + expect(result.filter.ids).toContain(hex); + expect(result.filter.ids).toContain(eventId); + }); + + it("should deduplicate IDs", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-i", `${hex},${hex}`]); + expect(result.filter.ids).toEqual([hex]); + }); + }); + + describe("relay hints", () => { + it("should extract relay hints from nevent", () => { + const eventId = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const nevent = nip19.neventEncode({ + id: eventId, + relays: ["wss://relay.damus.io"], + }); + const result = parseReqCommand(["-i", nevent]); + + expect(result.filter.ids).toEqual([eventId]); + 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(["-i", nevent]); + + result.relays?.forEach((url) => { + expect(url).toMatch(/^wss?:\/\//); + expect(url).toMatch(/\/$/); + }); + }); + + it("should collect relay hints from multiple nevents", () => { + const eventId1 = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const eventId2 = "b".repeat(64); + const nevent1 = nip19.neventEncode({ + id: eventId1, + relays: ["wss://relay.damus.io"], + }); + const nevent2 = nip19.neventEncode({ + id: eventId2, + relays: ["wss://nos.lol"], + }); + + const result = parseReqCommand(["-i", `${nevent1},${nevent2}`]); + + expect(result.relays).toBeDefined(); + expect(result.relays).toContain("wss://relay.damus.io/"); + expect(result.relays).toContain("wss://nos.lol/"); + }); + }); + + describe("error handling", () => { + it("should ignore invalid bech32", () => { + const result = parseReqCommand(["-i", "note1invalid"]); + expect(result.filter.ids).toBeUndefined(); + }); + + it("should ignore invalid nevent", () => { + const result = parseReqCommand(["-i", "nevent1invalid"]); + expect(result.filter.ids).toBeUndefined(); + }); + + it("should ignore naddr (not valid for direct ID lookup)", () => { + const pubkey = "b".repeat(64); + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: pubkey, + identifier: "test-article", + }); + const result = parseReqCommand(["-i", naddr]); + expect(result.filter.ids).toBeUndefined(); + }); + + it("should skip empty values", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-i", `${hex},,`]); + expect(result.filter.ids).toEqual([hex]); + }); + + it("should continue parsing after invalid values", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-i", `${hex1},invalid,${hex2}`]); + expect(result.filter.ids).toEqual([hex1, hex2]); + }); + }); + + describe("integration with other flags", () => { + it("should work alongside -e flag (both IDs and tags)", () => { + const directId = "a".repeat(64); + const tagEventId = "b".repeat(64); + + const result = parseReqCommand(["-i", directId, "-e", tagEventId]); + + expect(result.filter.ids).toEqual([directId]); + expect(result.filter["#e"]).toEqual([tagEventId]); + }); + + it("should work with kind and limit", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-i", hex, "-k", "1", "-l", "10"]); + + expect(result.filter.ids).toEqual([hex]); + expect(result.filter.kinds).toEqual([1]); + expect(result.filter.limit).toBe(10); + }); + + it("should work with relays", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-i", hex, "wss://relay.example.com"]); + + expect(result.filter.ids).toEqual([hex]); + expect(result.relays).toContain("wss://relay.example.com/"); + }); + + it("should accumulate across multiple -i flags", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-i", hex1, "-i", hex2]); + expect(result.filter.ids).toEqual([hex1, hex2]); + }); + }); + }); + 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 df9eb1a..1cc6e14 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -51,7 +51,7 @@ function parseCommaSeparated( /** * Parse REQ command arguments into a Nostr filter * Supports: - * - 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) + * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -i/--id (direct event lookup), -e (tag filtering: #e/#a), -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://), relay hints from nprofile/nevent/naddr are automatically extracted @@ -183,8 +183,40 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { break; } + case "-i": + case "--id": { + // Direct event lookup via filter.ids + // Support comma-separated: -i note1...,nevent1...,hex + if (!nextArg) { + i++; + break; + } + + let addedAny = false; + const values = nextArg.split(",").map((v) => v.trim()); + + for (const val of values) { + if (!val) continue; + + const parsed = parseIdIdentifier(val); + if (parsed) { + ids.add(parsed.id); + addedAny = true; + + // Collect relay hints from nevent + if (parsed.relays) { + relays.push(...parsed.relays); + } + } + } + + i += addedAny ? 2 : 1; + break; + } + case "-e": { - // Support comma-separated event identifiers: -e note1...,nevent1...,naddr1...,hex + // Tag-based filtering: -e note1...,nevent1...,naddr1...,hex + // Events go to #e tag, addresses go to #a tag if (!nextArg) { i++; break; @@ -198,13 +230,11 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { 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") { + // Route to appropriate tag filter based on type + if (parsed.type === "tag-event") { eventIds.add(parsed.value); + } else if (parsed.type === "tag-address") { + aTags.add(parsed.value); } // Collect relay hints @@ -564,24 +594,26 @@ function parseNpubOrHex(value: string): { } interface ParsedEventIdentifier { - type: "direct-event" | "direct-address" | "tag-event"; + type: "tag-event" | "tag-address"; value: string; relays?: string[]; } /** - * Parse event identifier - supports note, nevent, naddr, and hex event ID + * Parse event identifier for -e flag (tag filtering) + * All event IDs go to #e, addresses go to #a + * Supports: note, nevent, naddr, and hex event ID */ function parseEventIdentifier(value: string): ParsedEventIdentifier | null { if (!value) return null; - // nevent: direct event lookup with relay hints + // nevent: decode and route to #e tag if (value.startsWith("nevent")) { try { const decoded = nip19.decode(value); if (decoded.type === "nevent") { return { - type: "direct-event", + type: "tag-event", value: decoded.data.id, relays: decoded.data.relays ?.map((url) => { @@ -599,14 +631,14 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null { } } - // naddr: coordinate-based lookup with relay hints + // naddr: coordinate-based lookup with relay hints → #a tag 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", + type: "tag-address", value: coordinate, relays: decoded.data.relays ?.map((url) => { @@ -624,7 +656,7 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null { } } - // note1: tag-based filtering (existing behavior) + // note1: decode to event ID → #e tag if (value.startsWith("note")) { try { const decoded = nip19.decode(value); @@ -639,7 +671,7 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null { } } - // Hex: tag-based filtering (existing behavior) + // Hex: → #e tag if (isValidHexEventId(value)) { return { type: "tag-event", @@ -649,3 +681,62 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null { return null; } + +interface ParsedIdIdentifier { + id: string; + relays?: string[]; +} + +/** + * Parse event identifier for -i/--id flag (direct ID lookup via filter.ids) + * Supports: note, nevent, and hex event ID + */ +function parseIdIdentifier(value: string): ParsedIdIdentifier | null { + if (!value) return null; + + // nevent: decode and extract event ID + if (value.startsWith("nevent")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "nevent") { + return { + id: 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 + } + } + + // note1: decode to event ID + if (value.startsWith("note")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "note") { + return { + id: decoded.data, + }; + } + } catch { + // Not valid note, continue + } + } + + // Hex event ID + if (isValidHexEventId(value)) { + return { + id: normalizeHex(value), + }; + } + + return null; +} diff --git a/src/types/man.ts b/src/types/man.ts index 319765c..fb03990 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -175,10 +175,15 @@ export const manPages: Record = { flag: "-l, --limit ", description: "Maximum number of events to return", }, + { + flag: "-i, --id ", + description: + "Direct event lookup by ID (filter.ids). Fetch specific events by their ID. Supports note1, nevent1 (with relay hints), or raw hex. Comma-separated values supported: -i note1...,nevent1...,abc123...", + }, { flag: "-e ", description: - "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...", + "Tag-based filtering (#e/#a tags). Find events that reference the specified events or addresses. Supports note1, nevent1, naddr1, or raw hex. Comma-separated values supported: -e note1...,naddr1...", }, { flag: "-p ", @@ -256,6 +261,9 @@ export const manPages: Record = { "req -k 1 --since 1h relay.damus.io Get notes from last hour (manual relay override)", "req -k 1 --since 7d --until now Get notes from last week up to now", "req -k 1 --close-on-eose Get recent notes and close after EOSE", + "req -i note1abc123... Direct lookup: fetch event by ID", + "req -i nevent1... Direct lookup: fetch event by nevent (uses relay hints)", + "req -e note1abc123... -k 1 Tag filtering: find notes that reply to or reference event", "req -t nostr,grimoire,bitcoin -l 50 Get 50 events tagged #nostr, #grimoire, or #bitcoin", "req --tag a 30023:7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194:grimoire Get events referencing addressable event (#a tag)", "req -T r grimoire.rocks Get events referencing URL (#r tag)",