From cbd45eb1922ad4cff8125f9fe778571ae7529fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 09:58:54 +0000 Subject: [PATCH] feat: add custom tags and relays to zap command Extends the zap command to support custom tags and relay specification, enabling full translation from chat zap config to zap command. Changes: - Add -T/--tag flag to specify custom tags (type, value, optional relay hint) - Add -r/--relay flag to specify where zap receipt should be published - Update ZapWindow to accept and pass through relays prop - Update ChatMessageContextMenu to pass relays from zapConfig - Update man page with new options and examples - Add comprehensive tests for zap parser flag handling Example usage: zap npub... -T a 30311:pk:id wss://relay.example.com zap npub... -r wss://relay1.com -r wss://relay2.com --- src/components/ZapWindow.tsx | 4 + .../chat/ChatMessageContextMenu.tsx | 1 + src/lib/zap-parser.test.ts | 207 ++++++++++++++++++ src/lib/zap-parser.ts | 110 +++++++++- src/types/man.ts | 17 +- 5 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 src/lib/zap-parser.test.ts diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index d5da0cc..bee481c 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -62,6 +62,8 @@ export interface ZapWindowProps { * Used for protocol-specific tagging like NIP-53 live activity references */ customTags?: string[][]; + /** Relays where the zap receipt should be published */ + relays?: string[]; } // Default preset amounts in sats @@ -89,6 +91,7 @@ export function ZapWindow({ eventPointer, onClose, customTags, + relays: propsRelays, }: ZapWindowProps) { // Load event if we have a pointer and no recipient pubkey (derive from event author) const event = use$(() => { @@ -360,6 +363,7 @@ export function ZapWindow({ amountMillisats, comment, eventPointer, + relays: propsRelays, lnurl: lud16 || undefined, emojiTags, customTags, diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index 358d0b9..3c31f52 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -154,6 +154,7 @@ export function ChatMessageContextMenu({ addWindow("zap", { recipientPubkey: zapConfig.recipientPubkey, customTags: zapConfig.customTags, + relays: zapConfig.relays, }); }; diff --git a/src/lib/zap-parser.test.ts b/src/lib/zap-parser.test.ts new file mode 100644 index 0000000..b8b2ff5 --- /dev/null +++ b/src/lib/zap-parser.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; +import { parseZapCommand } from "./zap-parser"; + +describe("parseZapCommand", () => { + describe("positional arguments", () => { + it("should parse npub as recipient", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + // npub decodes to this hex pubkey + expect(result.recipientPubkey).toBe( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ); + }); + + it("should parse $me alias with active account", async () => { + const activePubkey = "abc123def456"; + const result = await parseZapCommand(["$me"], activePubkey); + expect(result.recipientPubkey).toBe(activePubkey); + }); + + it("should throw when $me used without active account", async () => { + await expect(parseZapCommand(["$me"])).rejects.toThrow( + "No active account", + ); + }); + + it("should throw for empty arguments", async () => { + await expect(parseZapCommand([])).rejects.toThrow( + "Recipient or event required", + ); + }); + }); + + describe("custom tags (-T, --tag)", () => { + it("should parse single custom tag with -T", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + ]); + expect(result.customTags).toEqual([["a", "30311:pubkey:identifier"]]); + }); + + it("should parse custom tag with --tag", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--tag", + "e", + "abc123", + ]); + expect(result.customTags).toEqual([["e", "abc123"]]); + }); + + it("should parse custom tag with relay hint", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "wss://relay.example.com", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier", "wss://relay.example.com/"], + ]); + }); + + it("should parse multiple custom tags", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "-T", + "e", + "goal123", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier"], + ["e", "goal123"], + ]); + }); + + it("should throw for incomplete tag", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + ]), + ).rejects.toThrow("Tag requires at least 2 arguments"); + }); + + it("should not include customTags when none provided", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.customTags).toBeUndefined(); + }); + }); + + describe("relays (-r, --relay)", () => { + it("should parse single relay with -r", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "wss://relay1.example.com", + ]); + expect(result.relays).toEqual(["wss://relay1.example.com/"]); + }); + + it("should parse relay with --relay", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--relay", + "wss://relay.example.com", + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should parse multiple relays", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "wss://relay1.example.com", + "-r", + "wss://relay2.example.com", + ]); + expect(result.relays).toEqual([ + "wss://relay1.example.com/", + "wss://relay2.example.com/", + ]); + }); + + it("should throw for missing relay URL", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + ]), + ).rejects.toThrow("Relay option requires a URL"); + }); + + it("should normalize relay URLs", async () => { + // normalizeRelayURL is liberal - it normalizes most inputs + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "relay.example.com", + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should not include relays when none provided", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.relays).toBeUndefined(); + }); + }); + + describe("combined flags", () => { + it("should parse tags and relays together", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "-r", + "wss://relay.example.com", + "-T", + "e", + "goalid", + "wss://relay.example.com", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier"], + ["e", "goalid", "wss://relay.example.com/"], + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should handle flags before positional args", async () => { + const result = await parseZapCommand([ + "-r", + "wss://relay.example.com", + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.recipientPubkey).toBe( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + }); + + describe("unknown options", () => { + it("should throw for unknown flags", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--unknown", + ]), + ).rejects.toThrow("Unknown option: --unknown"); + }); + }); +}); diff --git a/src/lib/zap-parser.ts b/src/lib/zap-parser.ts index 060ada7..79b84f4 100644 --- a/src/lib/zap-parser.ts +++ b/src/lib/zap-parser.ts @@ -13,6 +13,13 @@ export interface ParsedZapCommand { recipientPubkey: string; /** Optional event being zapped (adds context to the zap) */ eventPointer?: EventPointer | AddressPointer; + /** + * Custom tags to include in the zap request + * Used for protocol-specific tagging like NIP-53 live activity references + */ + customTags?: string[][]; + /** Relays where the zap receipt should be published */ + relays?: string[]; } /** @@ -23,6 +30,10 @@ export interface ParsedZapCommand { * - `zap ` - Zap an event (recipient derived from event author) * - `zap ` - Zap a specific person for a specific event * + * Options: + * - `-T, --tag [relay]` - Add custom tag (can be repeated) + * - `-r, --relay ` - Add relay for zap receipt publication (can be repeated) + * * Profile formats: npub, nprofile, hex pubkey, user@domain.com, $me * Event formats: note, nevent, naddr, hex event ID */ @@ -36,31 +47,110 @@ export async function parseZapCommand( ); } - const firstArg = args[0]; - const secondArg = args[1]; + // Parse flags and positional args + const positionalArgs: string[] = []; + const customTags: string[][] = []; + const relays: string[] = []; - // Case 1: Two arguments - zap + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === "-T" || arg === "--tag") { + // Parse tag: -T [relay-hint] + // Minimum 2 values after -T (type and value), optional relay hint + const tagType = args[i + 1]; + const tagValue = args[i + 2]; + + if (!tagType || !tagValue) { + throw new Error( + "Tag requires at least 2 arguments: -T [relay-hint]", + ); + } + + // Build tag array + const tag = [tagType, tagValue]; + + // Check if next arg is a relay hint (starts with ws:// or wss://) + const potentialRelay = args[i + 3]; + if ( + potentialRelay && + (potentialRelay.startsWith("ws://") || + potentialRelay.startsWith("wss://")) + ) { + try { + tag.push(normalizeRelayURL(potentialRelay)); + i += 4; + } catch { + // Not a valid relay, don't include + i += 3; + } + } else { + i += 3; + } + + customTags.push(tag); + } else if (arg === "-r" || arg === "--relay") { + // Parse relay: -r + const relayUrl = args[i + 1]; + if (!relayUrl) { + throw new Error("Relay option requires a URL: -r "); + } + + try { + relays.push(normalizeRelayURL(relayUrl)); + } catch { + throw new Error(`Invalid relay URL: ${relayUrl}`); + } + i += 2; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } else { + positionalArgs.push(arg); + i += 1; + } + } + + if (positionalArgs.length === 0) { + throw new Error( + "Recipient or event required. Usage: zap or zap or zap ", + ); + } + + const firstArg = positionalArgs[0]; + const secondArg = positionalArgs[1]; + + // Build result with optional custom tags and relays + const buildResult = ( + recipientPubkey: string, + eventPointer?: EventPointer | AddressPointer, + ): ParsedZapCommand => { + const result: ParsedZapCommand = { recipientPubkey }; + if (eventPointer) result.eventPointer = eventPointer; + if (customTags.length > 0) result.customTags = customTags; + if (relays.length > 0) result.relays = relays; + return result; + }; + + // Case 1: Two positional arguments - zap if (secondArg) { const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); const eventPointer = parseEventPointer(secondArg); - return { recipientPubkey, eventPointer }; + return buildResult(recipientPubkey, eventPointer); } - // Case 2: One argument - try event first, then profile + // Case 2: One positional argument - try event first, then profile // Events have more specific patterns (nevent, naddr, note) const eventPointer = tryParseEventPointer(firstArg); if (eventPointer) { // For events, we'll need to fetch the event to get the author // For now, we'll return a placeholder and let the component fetch it - return { - recipientPubkey: "", // Will be filled in by component from event author - eventPointer, - }; + return buildResult("", eventPointer); } // Must be a profile const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); - return { recipientPubkey }; + return buildResult(recipientPubkey); } /** diff --git a/src/types/man.ts b/src/types/man.ts index 230a292..9031732 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -618,9 +618,10 @@ export const manPages: Record = { zap: { name: "zap", section: "1", - synopsis: "zap [event]", + synopsis: + "zap [event] [-T [relay]] [-r ]", description: - "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", + "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Custom tags can be added for protocol-specific tagging (e.g., NIP-53 live activities). Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", options: [ { flag: "", @@ -631,6 +632,16 @@ export const manPages: Record = { flag: "", description: "Event to zap: note, nevent, naddr, hex ID (optional)", }, + { + flag: "-T, --tag [relay]", + description: + "Add custom tag to zap request (can be repeated). Used for protocol-specific tagging like NIP-53 a-tags", + }, + { + flag: "-r, --relay ", + description: + "Relay where zap receipt should be published (can be repeated)", + }, ], examples: [ "zap fiatjaf.com Zap a user by NIP-05", @@ -638,6 +649,8 @@ export const manPages: Record = { "zap nevent1... Zap an event (recipient = event author)", "zap npub1... nevent1... Zap a specific user for a specific event", "zap alice@domain.com naddr1... Zap with event context", + "zap npub1... -T a 30311:pk:id wss://relay.example.com Zap with live activity a-tag", + "zap npub1... -r wss://relay1.com -r wss://relay2.com Zap with custom relays", ], seeAlso: ["profile", "open", "wallet"], appId: "zap",