mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
feat: zap action for chat (#151)
* feat: add configurable zap tagging for chat messages Implements a protocol adapter interface for configuring how zap requests should be tagged for chat messages. This enables proper NIP-53 live activity zapping with appropriate a-tag and goal e-tag support. Changes: - Add ZapConfig interface to base-adapter for protocol-specific zap configuration - Add getZapConfig() method to ChatProtocolAdapter (default: unsupported) - Implement getZapConfig() in NIP-53 adapter with proper tagging: - Always a-tag the live activity (kind 30311) - If zapping host with goal, also e-tag the goal event - Add goal tag parsing to live-activity.ts and types - Update createZapRequest to accept custom tags parameter - Add Zap action to ChatMessageContextMenu (shown when supported) - Update ZapWindow to pass custom tags through to zap request - NIP-29 groups inherit default (unsupported) behavior * 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 * fix: include event pointer when zapping chat messages Pass the message event as eventPointer when opening ZapWindow from chat context menu. This enables: - Event preview in the zap window - Proper window title showing "Zap [username]" * feat: add zap command reconstruction for Edit feature Add zap case to command-reconstructor.ts so that clicking "Edit" on a zap window title shows a complete command with: - Recipient as npub - Event pointer as nevent/naddr - Custom tags with -T flags - Relays with -r flags This enables users to see and modify the full zap configuration. * fix: separate eventPointer and addressPointer for proper zap tagging - Refactor createZapRequest to use separate eventPointer (for e-tag) and addressPointer (for a-tag) instead of a union type - Remove duplicate p-tag issue (only tag recipient, not event author) - Remove duplicate e-tag issue (only one e-tag with relay hint if available) - Update ZapConfig interface to include addressPointer field - Update NIP-53 adapter to return addressPointer for live activity context - Update ChatMessageContextMenu to pass addressPointer from zapConfig - Update command-reconstructor to properly serialize addressPointer as -T a - Update ZapWindow to pass addressPointer to createZapRequest This ensures proper NIP-53 zap tagging: message author gets p-tag, live activity gets a-tag, and message event gets e-tag (all separate). * refactor: move eventPointer to ZapConfig for NIP-53 adapter - Add eventPointer field to ZapConfig interface for message e-tag - NIP-53 adapter now returns eventPointer from getZapConfig - ChatMessageContextMenu uses eventPointer from zapConfig directly - Remove goal logic from NIP-53 zap config (simplify for now) This gives the adapter full control over zap configuration, including which event to reference in the e-tag. * fix: update zap-parser to return separate eventPointer and addressPointer The ParsedZapCommand interface now properly separates: - eventPointer: for regular events (nevent, note, hex ID) → e-tag - addressPointer: for addressable events (naddr) → a-tag This aligns with ZapWindowProps which expects separate fields, fixing the issue where addressPointer from naddr was being passed as eventPointer and ignored. * feat: improve relay selection for zap requests with e+a tags When both eventPointer and addressPointer are provided: - Collect outbox relays from both semantic authors - Include relay hints from both pointers - Deduplicate and use combined relay set Priority order: 1. Explicit params.relays (respects CLI -r flags) 2. Semantic author outbox relays + pointer relay hints 3. Sender read relays (fallback) 4. Aggregator relays (final fallback) * fix: pass all zap props from WindowRenderer to ZapWindow WindowRenderer was only passing recipientPubkey and eventPointer, dropping addressPointer, customTags, and relays. This caused CLI flags like -T (custom tags) and -r (relays) to be ignored. Now all parsed zap command props flow through to ZapWindow and subsequently to createZapRequest. * refactor: let createZapRequest collect relays from both authors Remove top-level relays from NIP-53 zapConfig so createZapRequest can automatically collect outbox relays from both: - eventPointer.author (message author / zap recipient) - addressPointer.pubkey (stream host) The relay hints in the pointers are still included via the existing logic in createZapRequest. * fix: deduplicate explicit relays in createZapRequest Ensure params.relays is deduplicated before use, not just the automatically collected relays. This handles cases where CLI -r flags might specify duplicate relay URLs. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({
|
||||
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
|
||||
conversation={conversation}
|
||||
adapter={adapter}
|
||||
message={message}
|
||||
>
|
||||
{messageContent}
|
||||
</ChatMessageContextMenu>
|
||||
|
||||
@@ -234,6 +234,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
<ZapWindow
|
||||
recipientPubkey={window.props.recipientPubkey}
|
||||
eventPointer={window.props.eventPointer}
|
||||
addressPointer={window.props.addressPointer}
|
||||
customTags={window.props.customTags}
|
||||
relays={window.props.relays}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -54,10 +54,19 @@ import { getSemanticAuthor } from "@/lib/semantic-author";
|
||||
export interface ZapWindowProps {
|
||||
/** Recipient pubkey (who receives the zap) */
|
||||
recipientPubkey: string;
|
||||
/** Optional event being zapped (adds context) */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped (adds e-tag for context) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event context (adds a-tag, e.g., live activity) */
|
||||
addressPointer?: AddressPointer;
|
||||
/** Callback to close the window */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
// Default preset amounts in sats
|
||||
@@ -83,20 +92,15 @@ function formatAmount(amount: number): string {
|
||||
export function ZapWindow({
|
||||
recipientPubkey: initialRecipientPubkey,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
onClose,
|
||||
customTags,
|
||||
relays: propsRelays,
|
||||
}: ZapWindowProps) {
|
||||
// Load event if we have a pointer and no recipient pubkey (derive from event author)
|
||||
// Load event if we have an eventPointer and no recipient pubkey (derive from event author)
|
||||
const event = use$(() => {
|
||||
if (!eventPointer) return undefined;
|
||||
if ("id" in eventPointer) {
|
||||
return eventStore.event(eventPointer.id);
|
||||
}
|
||||
// AddressPointer
|
||||
return eventStore.replaceable(
|
||||
eventPointer.kind,
|
||||
eventPointer.pubkey,
|
||||
eventPointer.identifier,
|
||||
);
|
||||
return eventStore.event(eventPointer.id);
|
||||
}, [eventPointer]);
|
||||
|
||||
// Resolve recipient: use provided pubkey or derive from semantic author
|
||||
@@ -357,8 +361,11 @@ export function ZapWindow({
|
||||
amountMillisats,
|
||||
comment,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
relays: propsRelays,
|
||||
lnurl: lud16 || undefined,
|
||||
emojiTags,
|
||||
customTags,
|
||||
});
|
||||
|
||||
const serializedZapRequest = serializeZapRequest(zapRequest);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
import type { Conversation, Message } from "@/types/chat";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Reply,
|
||||
MessageSquare,
|
||||
Smile,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -37,6 +38,8 @@ interface ChatMessageContextMenuProps {
|
||||
onReply?: () => void;
|
||||
conversation?: Conversation;
|
||||
adapter?: ChatProtocolAdapter;
|
||||
/** Message object for protocol-specific actions like zapping */
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,7 @@ export function ChatMessageContextMenu({
|
||||
onReply,
|
||||
conversation,
|
||||
adapter,
|
||||
message,
|
||||
}: ChatMessageContextMenuProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
@@ -63,6 +67,12 @@ export function ChatMessageContextMenu({
|
||||
// Extract context emojis from the conversation
|
||||
const contextEmojis = getEmojiTags(event);
|
||||
|
||||
// Get zap configuration from adapter
|
||||
const zapConfig = useMemo(() => {
|
||||
if (!adapter || !message || !conversation) return null;
|
||||
return adapter.getZapConfig(message, conversation);
|
||||
}, [adapter, message, conversation]);
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
// For replaceable/parameterized replaceable events, use AddressPointer
|
||||
@@ -138,6 +148,18 @@ export function ChatMessageContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const openZapWindow = () => {
|
||||
if (!zapConfig || !zapConfig.supported) return;
|
||||
|
||||
addWindow("zap", {
|
||||
recipientPubkey: zapConfig.recipientPubkey,
|
||||
eventPointer: zapConfig.eventPointer,
|
||||
addressPointer: zapConfig.addressPointer,
|
||||
customTags: zapConfig.customTags,
|
||||
relays: zapConfig.relays,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
@@ -170,6 +192,12 @@ export function ChatMessageContextMenu({
|
||||
<Smile className="size-4 mr-2" />
|
||||
React
|
||||
</ContextMenuItem>
|
||||
{zapConfig?.supported && (
|
||||
<ContextMenuItem onClick={openZapWindow}>
|
||||
<Zap className="size-4 mr-2" />
|
||||
Zap
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,36 @@ import type {
|
||||
GetActionsOptions,
|
||||
} from "@/types/chat-actions";
|
||||
|
||||
/**
|
||||
* Zap configuration for chat messages
|
||||
* Defines how zap requests should be constructed for protocol-specific tagging
|
||||
*/
|
||||
export interface ZapConfig {
|
||||
/** Whether zapping is supported for this message/conversation */
|
||||
supported: boolean;
|
||||
/** Reason why zapping is not supported (if supported=false) */
|
||||
unsupportedReason?: string;
|
||||
/** Recipient pubkey (who receives the sats) */
|
||||
recipientPubkey: string;
|
||||
/** Event being zapped for e-tag (e.g., chat message) */
|
||||
eventPointer?: {
|
||||
id: string;
|
||||
author?: string;
|
||||
relays?: string[];
|
||||
};
|
||||
/** Addressable event context for a-tag (e.g., live activity) */
|
||||
addressPointer?: {
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
relays?: string[];
|
||||
};
|
||||
/** Custom tags to include in the zap request (beyond standard p/amount/relays) */
|
||||
customTags?: string[][];
|
||||
/** Relays where the zap receipt should be published */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob attachment metadata for imeta tags (NIP-92)
|
||||
*/
|
||||
@@ -180,6 +210,26 @@ export abstract class ChatProtocolAdapter {
|
||||
*/
|
||||
leaveConversation?(conversation: Conversation): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get zap configuration for a message
|
||||
* Returns configuration for how zap requests should be constructed,
|
||||
* including protocol-specific tagging (e.g., a-tag for live activities)
|
||||
*
|
||||
* Default implementation returns unsupported.
|
||||
* Override in adapters that support zapping.
|
||||
*
|
||||
* @param message - The message being zapped
|
||||
* @param conversation - The conversation context
|
||||
* @returns ZapConfig with supported=true and tagging info, or supported=false with reason
|
||||
*/
|
||||
getZapConfig(_message: Message, _conversation: Conversation): ZapConfig {
|
||||
return {
|
||||
supported: false,
|
||||
unsupportedReason: "Zaps are not supported for this protocol",
|
||||
recipientPubkey: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available actions for this protocol
|
||||
* Actions are protocol-specific slash commands like /join, /leave, etc.
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import {
|
||||
ChatProtocolAdapter,
|
||||
type SendMessageOptions,
|
||||
type ZapConfig,
|
||||
} from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
@@ -214,6 +218,7 @@ export class Nip53Adapter extends ChatProtocolAdapter {
|
||||
totalParticipants: activity.totalParticipants,
|
||||
hashtags: activity.hashtags,
|
||||
relays: chatRelays,
|
||||
goal: activity.goal,
|
||||
},
|
||||
},
|
||||
unreadCount: 0,
|
||||
@@ -549,6 +554,66 @@ export class Nip53Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zap configuration for a message in a live activity
|
||||
*
|
||||
* NIP-53 zap tagging rules:
|
||||
* - p-tag: message author (recipient)
|
||||
* - e-tag: message event being zapped
|
||||
* - a-tag: live activity context
|
||||
*/
|
||||
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
|
||||
const activityAddress = conversation.metadata?.activityAddress;
|
||||
const liveActivity = conversation.metadata?.liveActivity as
|
||||
| {
|
||||
relays?: string[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!activityAddress) {
|
||||
return {
|
||||
supported: false,
|
||||
unsupportedReason: "Missing activity address",
|
||||
recipientPubkey: "",
|
||||
};
|
||||
}
|
||||
|
||||
const { pubkey: activityPubkey, identifier } = activityAddress;
|
||||
|
||||
// Get relays
|
||||
const relays =
|
||||
liveActivity?.relays && liveActivity.relays.length > 0
|
||||
? liveActivity.relays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
|
||||
// Build eventPointer for the message being zapped (e-tag)
|
||||
const eventPointer = {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
relays,
|
||||
};
|
||||
|
||||
// Build addressPointer for the live activity (a-tag)
|
||||
const addressPointer = {
|
||||
kind: 30311,
|
||||
pubkey: activityPubkey,
|
||||
identifier,
|
||||
relays,
|
||||
};
|
||||
|
||||
// Don't pass top-level relays - let createZapRequest collect outbox relays
|
||||
// from both eventPointer.author (recipient) and addressPointer.pubkey (stream host)
|
||||
// The relay hints in the pointers will also be included
|
||||
return {
|
||||
supported: true,
|
||||
recipientPubkey: message.author,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
* First checks EventStore, then fetches from relays if needed
|
||||
|
||||
@@ -96,6 +96,74 @@ export function reconstructCommand(window: WindowInstance): string {
|
||||
case "debug":
|
||||
return "debug";
|
||||
|
||||
case "zap": {
|
||||
// Reconstruct zap command from props
|
||||
const parts: string[] = ["zap"];
|
||||
|
||||
// Add recipient pubkey (encode as npub for readability)
|
||||
if (props.recipientPubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(props.recipientPubkey);
|
||||
parts.push(npub);
|
||||
} catch {
|
||||
parts.push(props.recipientPubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event pointer if present (e-tag context)
|
||||
if (props.eventPointer) {
|
||||
const pointer = props.eventPointer;
|
||||
try {
|
||||
const nevent = nip19.neventEncode({
|
||||
id: pointer.id,
|
||||
relays: pointer.relays,
|
||||
author: pointer.author,
|
||||
kind: pointer.kind,
|
||||
});
|
||||
parts.push(nevent);
|
||||
} catch {
|
||||
// Fallback to raw ID
|
||||
parts.push(pointer.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add address pointer if present (a-tag context, e.g., live activity)
|
||||
if (props.addressPointer) {
|
||||
const pointer = props.addressPointer;
|
||||
// Use -T a to add the a-tag as coordinate
|
||||
parts.push(
|
||||
"-T",
|
||||
"a",
|
||||
`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`,
|
||||
);
|
||||
if (pointer.relays?.[0]) {
|
||||
parts.push(pointer.relays[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom tags
|
||||
if (props.customTags && props.customTags.length > 0) {
|
||||
for (const tag of props.customTags) {
|
||||
if (tag.length >= 2) {
|
||||
parts.push("-T", tag[0], tag[1]);
|
||||
// Add relay hint if present
|
||||
if (tag[2]) {
|
||||
parts.push(tag[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add relays
|
||||
if (props.relays && props.relays.length > 0) {
|
||||
for (const relay of props.relays) {
|
||||
parts.push("-r", relay);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
case "chat": {
|
||||
// Reconstruct chat command from protocol and identifier
|
||||
const { protocol, identifier } = props;
|
||||
|
||||
@@ -21,14 +21,21 @@ export interface ZapRequestParams {
|
||||
amountMillisats: number;
|
||||
/** Optional comment/message */
|
||||
comment?: string;
|
||||
/** Optional event being zapped */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped (adds e-tag) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event context (adds a-tag, e.g., live activity) */
|
||||
addressPointer?: AddressPointer;
|
||||
/** Relays where zap receipt should be published */
|
||||
relays?: string[];
|
||||
/** LNURL for the recipient */
|
||||
lnurl?: string;
|
||||
/** NIP-30 custom emoji tags */
|
||||
emojiTags?: EmojiTag[];
|
||||
/**
|
||||
* Custom tags to include in the zap request (beyond standard p/amount/relays)
|
||||
* Used for additional protocol-specific tagging
|
||||
*/
|
||||
customTags?: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,12 +57,53 @@ export async function createZapRequest(
|
||||
}
|
||||
|
||||
// Get relays for zap receipt publication
|
||||
let relays = params.relays;
|
||||
// Priority: explicit params.relays > semantic author relays > sender read relays > aggregators
|
||||
let relays: string[] | undefined = params.relays
|
||||
? [...new Set(params.relays)] // Deduplicate explicit relays
|
||||
: undefined;
|
||||
|
||||
if (!relays || relays.length === 0) {
|
||||
// Use sender's read relays (where they want to receive zap receipts)
|
||||
const senderReadRelays =
|
||||
(await relayListCache.getInboxRelays(account.pubkey)) || [];
|
||||
relays = senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS;
|
||||
const collectedRelays: string[] = [];
|
||||
|
||||
// Collect outbox relays from semantic authors (event author and/or addressable event pubkey)
|
||||
const authorsToQuery: string[] = [];
|
||||
if (params.eventPointer?.author) {
|
||||
authorsToQuery.push(params.eventPointer.author);
|
||||
}
|
||||
if (params.addressPointer?.pubkey) {
|
||||
authorsToQuery.push(params.addressPointer.pubkey);
|
||||
}
|
||||
|
||||
// Deduplicate authors
|
||||
const uniqueAuthors = [...new Set(authorsToQuery)];
|
||||
|
||||
// Fetch outbox relays for each author
|
||||
for (const authorPubkey of uniqueAuthors) {
|
||||
const authorOutboxes =
|
||||
(await relayListCache.getOutboxRelays(authorPubkey)) || [];
|
||||
collectedRelays.push(...authorOutboxes);
|
||||
}
|
||||
|
||||
// Include relay hints from pointers
|
||||
if (params.eventPointer?.relays) {
|
||||
collectedRelays.push(...params.eventPointer.relays);
|
||||
}
|
||||
if (params.addressPointer?.relays) {
|
||||
collectedRelays.push(...params.addressPointer.relays);
|
||||
}
|
||||
|
||||
// Deduplicate collected relays
|
||||
const uniqueRelays = [...new Set(collectedRelays)];
|
||||
|
||||
if (uniqueRelays.length > 0) {
|
||||
relays = uniqueRelays;
|
||||
} else {
|
||||
// Fallback to sender's read relays (where they want to receive zap receipts)
|
||||
const senderReadRelays =
|
||||
(await relayListCache.getInboxRelays(account.pubkey)) || [];
|
||||
relays =
|
||||
senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS;
|
||||
}
|
||||
}
|
||||
|
||||
// Build tags
|
||||
@@ -70,27 +118,31 @@ export async function createZapRequest(
|
||||
tags.push(["lnurl", params.lnurl]);
|
||||
}
|
||||
|
||||
// Add event reference if zapping an event
|
||||
// Add event reference if zapping an event (e-tag)
|
||||
if (params.eventPointer) {
|
||||
if ("id" in params.eventPointer) {
|
||||
// Regular event (e tag)
|
||||
tags.push(["e", params.eventPointer.id]);
|
||||
// Include author if available
|
||||
if (params.eventPointer.author) {
|
||||
tags.push(["p", params.eventPointer.author]);
|
||||
}
|
||||
// Include relay hints
|
||||
if (params.eventPointer.relays && params.eventPointer.relays.length > 0) {
|
||||
tags.push(["e", params.eventPointer.id, params.eventPointer.relays[0]]);
|
||||
}
|
||||
const relayHint = params.eventPointer.relays?.[0] || "";
|
||||
if (relayHint) {
|
||||
tags.push(["e", params.eventPointer.id, relayHint]);
|
||||
} else {
|
||||
tags.push(["e", params.eventPointer.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add addressable event reference (a-tag) - for NIP-53 live activities, etc.
|
||||
if (params.addressPointer) {
|
||||
const coordinate = `${params.addressPointer.kind}:${params.addressPointer.pubkey}:${params.addressPointer.identifier}`;
|
||||
const relayHint = params.addressPointer.relays?.[0] || "";
|
||||
if (relayHint) {
|
||||
tags.push(["a", coordinate, relayHint]);
|
||||
} else {
|
||||
// Addressable event (a tag)
|
||||
const coordinate = `${params.eventPointer.kind}:${params.eventPointer.pubkey}:${params.eventPointer.identifier}`;
|
||||
tags.push(["a", coordinate]);
|
||||
// Include relay hint if available
|
||||
if (params.eventPointer.relays && params.eventPointer.relays.length > 0) {
|
||||
tags.push(["a", coordinate, params.eventPointer.relays[0]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom tags (protocol-specific like NIP-53 live activity references)
|
||||
if (params.customTags) {
|
||||
for (const tag of params.customTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity {
|
||||
participants,
|
||||
hashtags: getTagValues(event, "t"),
|
||||
relays: getTagValues(event, "relays"),
|
||||
goal: getTagValue(event, "goal"),
|
||||
lastUpdate: event.created_at || Date.now() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,17 @@ import type { EventPointer, AddressPointer } from "./open-parser";
|
||||
export interface ParsedZapCommand {
|
||||
/** Recipient pubkey (who receives the zap) */
|
||||
recipientPubkey: string;
|
||||
/** Optional event being zapped (adds context to the zap) */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped - regular events (e-tag) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event being zapped - replaceable events (a-tag) */
|
||||
addressPointer?: 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 +32,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 +49,117 @@ 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>
|
||||
if (secondArg) {
|
||||
const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey);
|
||||
const eventPointer = parseEventPointer(secondArg);
|
||||
return { recipientPubkey, eventPointer };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: One argument - try event first, then profile
|
||||
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,
|
||||
pointer?: EventPointer | AddressPointer,
|
||||
): ParsedZapCommand => {
|
||||
const result: ParsedZapCommand = { recipientPubkey };
|
||||
// Separate EventPointer from AddressPointer based on presence of 'id' vs 'kind'
|
||||
if (pointer) {
|
||||
if ("id" in pointer) {
|
||||
result.eventPointer = pointer;
|
||||
} else if ("kind" in pointer) {
|
||||
result.addressPointer = pointer;
|
||||
}
|
||||
}
|
||||
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 pointer = parseEventPointer(secondArg);
|
||||
return buildResult(recipientPubkey, pointer);
|
||||
}
|
||||
|
||||
// Case 2: One positional argument - try event first, then profile
|
||||
// Events have more specific patterns (nevent, naddr, note)
|
||||
const eventPointer = tryParseEventPointer(firstArg);
|
||||
if (eventPointer) {
|
||||
const pointer = tryParseEventPointer(firstArg);
|
||||
if (pointer) {
|
||||
// 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("", pointer);
|
||||
}
|
||||
|
||||
// Must be a profile
|
||||
const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey);
|
||||
return { recipientPubkey };
|
||||
return buildResult(recipientPubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface LiveActivityMetadata {
|
||||
totalParticipants?: number;
|
||||
hashtags: string[];
|
||||
relays: string[];
|
||||
goal?: string; // Event ID of a kind 9041 zap goal
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ParsedLiveActivity {
|
||||
// Additional
|
||||
hashtags: string[]; // 't' tags
|
||||
relays: string[]; // 'relays' tag values
|
||||
goal?: string; // Event ID of a kind 9041 zap goal
|
||||
|
||||
// Computed
|
||||
lastUpdate: number; // event.created_at
|
||||
|
||||
@@ -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