feat: add -i/--id flag for direct event ID filtering in REQ command

Add a new -i/--id flag for direct event lookup via filter.ids, and clarify
-e flag behavior for tag-based filtering (#e/#a tags).

Changes:
- Add -i/--id flag: accepts note1, nevent, or hex event IDs for direct lookup
- Clarify -e flag: now always routes to #e/#a tags (including nevent)
- Update man page with new flag documentation and examples
- Add comprehensive tests for the new -i/--id flag

This aligns with nak's behavior where -i is for direct ID filtering and
-e is for tag-based event references.
This commit is contained in:
Claude
2026-01-22 13:07:50 +00:00
parent 93ffd365f5
commit 337faa0dbb
3 changed files with 324 additions and 36 deletions

View File

@@ -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);

View File

@@ -51,7 +51,7 @@ function parseCommaSeparated<T>(
/**
* 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;
}

View File

@@ -175,10 +175,15 @@ export const manPages: Record<string, ManPageEntry> = {
flag: "-l, --limit <number>",
description: "Maximum number of events to return",
},
{
flag: "-i, --id <note|nevent|hex>",
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 <note|nevent|naddr|hex>",
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 <npub|hex|nip05|$me|$contacts>",
@@ -256,6 +261,9 @@ export const manPages: Record<string, ManPageEntry> = {
"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)",