mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
* 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>
317 lines
8.2 KiB
TypeScript
317 lines
8.2 KiB
TypeScript
import { WindowInstance } from "@/types/app";
|
|
import { nip19 } from "nostr-tools";
|
|
|
|
/**
|
|
* Reconstructs the command string that would have created this window.
|
|
* Used for windows created before commandString tracking was added.
|
|
*/
|
|
export function reconstructCommand(window: WindowInstance): string {
|
|
const { appId, props } = window;
|
|
|
|
try {
|
|
switch (appId) {
|
|
case "nip":
|
|
return `nip ${props.number || "01"}`;
|
|
|
|
case "kind":
|
|
return `kind ${props.number || "1"}`;
|
|
|
|
case "kinds":
|
|
return "kinds";
|
|
|
|
case "man":
|
|
return props.cmd && props.cmd !== "help" ? `man ${props.cmd}` : "help";
|
|
|
|
case "profile": {
|
|
// Try to encode pubkey as npub for readability
|
|
if (props.pubkey) {
|
|
try {
|
|
const npub = nip19.npubEncode(props.pubkey);
|
|
return `profile ${npub}`;
|
|
} catch {
|
|
// If encoding fails, use hex
|
|
return `profile ${props.pubkey}`;
|
|
}
|
|
}
|
|
return "profile";
|
|
}
|
|
|
|
case "open": {
|
|
// Handle pointer structure from parseOpenCommand
|
|
if (!props.pointer) return "open";
|
|
|
|
const pointer = props.pointer;
|
|
|
|
try {
|
|
// EventPointer (has id field)
|
|
if ("id" in pointer) {
|
|
const nevent = nip19.neventEncode({
|
|
id: pointer.id,
|
|
relays: pointer.relays,
|
|
author: pointer.author,
|
|
kind: pointer.kind,
|
|
});
|
|
return `open ${nevent}`;
|
|
}
|
|
|
|
// AddressPointer (has kind, pubkey, identifier)
|
|
if ("kind" in pointer) {
|
|
const naddr = nip19.naddrEncode({
|
|
kind: pointer.kind,
|
|
pubkey: pointer.pubkey,
|
|
identifier: pointer.identifier,
|
|
relays: pointer.relays,
|
|
});
|
|
return `open ${naddr}`;
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to encode open command:", error);
|
|
// Fallback to raw pointer display
|
|
if ("id" in pointer) {
|
|
return `open ${pointer.id}`;
|
|
}
|
|
}
|
|
|
|
return "open";
|
|
}
|
|
|
|
case "relay":
|
|
return props.url ? `relay ${props.url}` : "relay";
|
|
|
|
case "conn":
|
|
return "conn";
|
|
|
|
case "encode":
|
|
// Best effort reconstruction
|
|
return props.args ? `encode ${props.args.join(" ")}` : "encode";
|
|
|
|
case "decode":
|
|
return props.args ? `decode ${props.args[0] || ""}` : "decode";
|
|
|
|
case "req": {
|
|
// Reconstruct req command from filter object
|
|
return reconstructReqCommand(props);
|
|
}
|
|
|
|
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;
|
|
|
|
if (!identifier) {
|
|
return "chat";
|
|
}
|
|
|
|
// NIP-29 relay groups: chat relay'group-id
|
|
if (protocol === "nip-29" && identifier.type === "group") {
|
|
const relayUrl = identifier.relays?.[0] || "";
|
|
const groupId = identifier.value;
|
|
|
|
if (relayUrl && groupId) {
|
|
// Strip wss:// prefix for cleaner command
|
|
const cleanRelay = relayUrl.replace(/^wss?:\/\//, "");
|
|
return `chat ${cleanRelay}'${groupId}`;
|
|
}
|
|
}
|
|
|
|
// NIP-53 live activities: chat naddr1...
|
|
if (protocol === "nip-53" && identifier.type === "live-activity") {
|
|
const { pubkey, identifier: dTag } = identifier.value || {};
|
|
const relays = identifier.relays;
|
|
|
|
if (pubkey && dTag) {
|
|
try {
|
|
const naddr = nip19.naddrEncode({
|
|
kind: 30311,
|
|
pubkey,
|
|
identifier: dTag,
|
|
relays,
|
|
});
|
|
return `chat ${naddr}`;
|
|
} catch {
|
|
// Fallback if encoding fails
|
|
}
|
|
}
|
|
}
|
|
|
|
return "chat";
|
|
}
|
|
|
|
default:
|
|
return appId; // Fallback to just the command name
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to reconstruct command:", error);
|
|
return appId; // Fallback to just the command name
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconstructs a req command from its filter props.
|
|
* This is complex as req has many flags.
|
|
*/
|
|
function reconstructReqCommand(props: any): string {
|
|
const parts = ["req"];
|
|
const filter = props.filter || {};
|
|
|
|
// Kinds
|
|
if (filter.kinds && filter.kinds.length > 0) {
|
|
parts.push("-k", filter.kinds.join(","));
|
|
}
|
|
|
|
// Authors (convert hex to npub if possible)
|
|
if (filter.authors && filter.authors.length > 0) {
|
|
const authors = filter.authors.map((hex: string) => {
|
|
try {
|
|
return nip19.npubEncode(hex);
|
|
} catch {
|
|
return hex;
|
|
}
|
|
});
|
|
parts.push("-a", authors.join(","));
|
|
}
|
|
|
|
// Limit
|
|
if (filter.limit) {
|
|
parts.push("-l", filter.limit.toString());
|
|
}
|
|
|
|
// Event IDs (#e tag)
|
|
if (filter["#e"] && filter["#e"].length > 0) {
|
|
parts.push("-e", filter["#e"].join(","));
|
|
}
|
|
|
|
// Mentioned pubkeys (#p tag)
|
|
if (filter["#p"] && filter["#p"].length > 0) {
|
|
const pubkeys = filter["#p"].map((hex: string) => {
|
|
try {
|
|
return nip19.npubEncode(hex);
|
|
} catch {
|
|
return hex;
|
|
}
|
|
});
|
|
parts.push("-p", pubkeys.join(","));
|
|
}
|
|
|
|
// Hashtags (#t tag)
|
|
if (filter["#t"] && filter["#t"].length > 0) {
|
|
parts.push("-t", filter["#t"].join(","));
|
|
}
|
|
|
|
// D-tags (#d tag)
|
|
if (filter["#d"] && filter["#d"].length > 0) {
|
|
parts.push("-d", filter["#d"].join(","));
|
|
}
|
|
|
|
// Generic tags
|
|
for (const [key, value] of Object.entries(filter)) {
|
|
if (
|
|
key.startsWith("#") &&
|
|
key.length === 2 &&
|
|
!["#e", "#p", "#t", "#d"].includes(key)
|
|
) {
|
|
const letter = key[1];
|
|
const values = value as string[];
|
|
if (values.length > 0) {
|
|
parts.push("--tag", letter, values.join(","));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Time ranges
|
|
if (filter.since) {
|
|
parts.push("--since", filter.since.toString());
|
|
}
|
|
|
|
if (filter.until) {
|
|
parts.push("--until", filter.until.toString());
|
|
}
|
|
|
|
// Search
|
|
if (filter.search) {
|
|
parts.push("--search", filter.search);
|
|
}
|
|
|
|
// Close on EOSE
|
|
if (props.closeOnEose) {
|
|
parts.push("--close-on-eose");
|
|
}
|
|
|
|
// Relays
|
|
if (props.relays && props.relays.length > 0) {
|
|
parts.push(...props.relays);
|
|
}
|
|
|
|
return parts.join(" ");
|
|
}
|