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
This commit is contained in:
Claude
2026-01-19 09:58:54 +00:00
parent 5415f4f64f
commit cbd45eb192
5 changed files with 327 additions and 12 deletions

View File

@@ -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,

View File

@@ -154,6 +154,7 @@ export function ChatMessageContextMenu({
addWindow("zap", {
recipientPubkey: zapConfig.recipientPubkey,
customTags: zapConfig.customTags,
relays: zapConfig.relays,
});
};

207
src/lib/zap-parser.test.ts Normal file
View File

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

View File

@@ -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 <event>` - Zap an event (recipient derived from event author)
* - `zap <profile> <event>` - Zap a specific person for a specific event
*
* Options:
* - `-T, --tag <type> <value> [relay]` - Add custom tag (can be repeated)
* - `-r, --relay <url>` - 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 <profile> <event>
let i = 0;
while (i < args.length) {
const arg = args[i];
if (arg === "-T" || arg === "--tag") {
// Parse tag: -T <type> <value> [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 <type> <value> [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 <url>
const relayUrl = args[i + 1];
if (!relayUrl) {
throw new Error("Relay option requires a URL: -r <url>");
}
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 <profile> or zap <event> or zap <profile> <event>",
);
}
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 <profile> <event>
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);
}
/**

View File

@@ -618,9 +618,10 @@ export const manPages: Record<string, ManPageEntry> = {
zap: {
name: "zap",
section: "1",
synopsis: "zap <profile|event> [event]",
synopsis:
"zap <profile|event> [event] [-T <type> <value> [relay]] [-r <relay>]",
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: "<profile>",
@@ -631,6 +632,16 @@ export const manPages: Record<string, ManPageEntry> = {
flag: "<event>",
description: "Event to zap: note, nevent, naddr, hex ID (optional)",
},
{
flag: "-T, --tag <type> <value> [relay]",
description:
"Add custom tag to zap request (can be repeated). Used for protocol-specific tagging like NIP-53 a-tags",
},
{
flag: "-r, --relay <url>",
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<string, ManPageEntry> = {
"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",