Files
grimoire/src/lib/command-reconstructor.ts
Alejandro 3f811ed072 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>
2026-01-19 12:16:51 +01:00

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