mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
207
src/lib/zap-parser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user