feat: more flexible -e flag

This commit is contained in:
Alejandro Gómez
2025-12-18 17:08:11 +01:00
parent f6f813d382
commit 3f45cebaee
3 changed files with 418 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseReqCommand } from "./req-parser"; import { parseReqCommand } from "./req-parser";
import { nip19 } from "nostr-tools";
describe("parseReqCommand", () => { describe("parseReqCommand", () => {
describe("kind flag (-k, --kind)", () => { 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)", () => { describe("pubkey tag flag (-p)", () => {
it("should parse hex pubkey for #p tag", () => { it("should parse hex pubkey for #p tag", () => {
const hex = "a".repeat(64); const hex = "a".repeat(64);

View File

@@ -45,10 +45,10 @@ function parseCommaSeparated<T>(
/** /**
* Parse REQ command arguments into a Nostr filter * Parse REQ command arguments into a Nostr filter
* Supports: * 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 * - Time: --since, --until
* - Search: --search * - 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) * - Options: --close-on-eose (close stream after EOSE, default: stream stays open)
*/ */
export function parseReqCommand(args: string[]): ParsedReqCommand { export function parseReqCommand(args: string[]): ParsedReqCommand {
@@ -61,7 +61,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
// Use sets for deduplication during accumulation // Use sets for deduplication during accumulation
const kinds = new Set<number>(); const kinds = new Set<number>();
const authors = new Set<string>(); const authors = new Set<string>();
const eventIds = new Set<string>(); const ids = new Set<string>(); // For filter.ids (direct event lookup)
const eventIds = new Set<string>(); // For filter["#e"] (tag-based event lookup)
const aTags = new Set<string>(); // For filter["#a"] (coordinate-based lookup)
const pTags = new Set<string>(); const pTags = new Set<string>();
const pTagsUppercase = new Set<string>(); const pTagsUppercase = new Set<string>();
const tTags = new Set<string>(); const tTags = new Set<string>();
@@ -165,16 +167,38 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
} }
case "-e": { case "-e": {
// Support comma-separated event IDs: -e id1,id2,id3 // Support comma-separated event identifiers: -e note1...,nevent1...,naddr1...,hex
if (!nextArg) { if (!nextArg) {
i++; i++;
break; break;
} }
const addedAny = parseCommaSeparated(
nextArg, let addedAny = false;
parseNoteOrHex, const values = nextArg.split(",").map((v) => v.trim());
eventIds,
); 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; i += addedAny ? 2 : 1;
break; break;
} }
@@ -367,7 +391,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
// Convert accumulated sets to filter arrays (with deduplication) // Convert accumulated sets to filter arrays (with deduplication)
if (kinds.size > 0) filter.kinds = Array.from(kinds); if (kinds.size > 0) filter.kinds = Array.from(kinds);
if (authors.size > 0) filter.authors = Array.from(authors); 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 (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 (pTags.size > 0) filter["#p"] = Array.from(pTags);
if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase); if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase);
if (tTags.size > 0) filter["#t"] = Array.from(tTags); if (tTags.size > 0) filter["#t"] = Array.from(tTags);
@@ -487,27 +513,88 @@ function parseNpubOrHex(value: string): {
return { pubkey: null }; 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; 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")) { if (value.startsWith("note")) {
try { try {
const decoded = nip19.decode(value); const decoded = nip19.decode(value);
if (decoded.type === "note") { if (decoded.type === "note") {
return decoded.data; return {
type: "tag-event",
value: decoded.data,
};
} }
} catch { } catch {
// Not valid note, continue // Not valid note, continue
} }
} }
// Check if it's hex event ID // Hex: tag-based filtering (existing behavior)
if (isValidHexEventId(value)) { if (isValidHexEventId(value)) {
return normalizeHex(value); return {
type: "tag-event",
value: normalizeHex(value),
};
} }
return null; return null;

View File

@@ -164,9 +164,9 @@ export const manPages: Record<string, ManPageEntry> = {
description: "Maximum number of events to return", description: "Maximum number of events to return",
}, },
{ {
flag: "-e <id>", flag: "-e <note|nevent|naddr|hex>",
description: 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 <npub|hex|nip05|$me|$contacts>", flag: "-p <npub|hex|nip05|$me|$contacts>",