mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 17:19:27 +02:00
feat: auto-convert $event spells to use #a tags for replaceable events
Handles the edge case where $event parameter spells reference replaceable events. Instead of using event IDs in #e tags, the system now automatically detects replaceable/parameterized replaceable events and uses address coordinates in #a tags instead. This ensures replaceable events are properly referenced by their coordinate (kind:pubkey:d-tag) rather than their event ID, which aligns with Nostr best practices since replaceable events can be superseded. Implementation: - Add getEventCoordinate() utility to construct coordinates from events - Detect addressable kinds using isAddressableKind() helper - Pass both targetEventId and targetAddress to applySpellParameters() - When substituting $event in #e tags and targetAddress exists: * Remove $event from #e tags * Add targetAddress to #a tags instead * Preserve non-$event values in original tags - Keep using event ID for filter.ids (direct ID lookups) Changes: - spell-conversion.ts: Add getEventCoordinate(), update applySpellParameters - EventDetailViewer.tsx: Calculate and pass targetAddress for replaceable events - spell-conversion.test.ts: Add comprehensive tests (98 tests passing) Tested: - Regular events continue using #e tags (no change) - Replaceable events (0, 3, 10000-19999) use #a tags with kind:pubkey: format - Parameterized replaceable (30000-39999) use #a tags with kind:pubkey:d-tag - Mixed #e filters preserve non-$event values correctly - Existing #a values are preserved when converting from #e
This commit is contained in:
@@ -29,6 +29,7 @@ import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
|
||||
import {
|
||||
applySpellParameters,
|
||||
detectCommandType,
|
||||
getEventCoordinate,
|
||||
} from "@/lib/spell-conversion";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
@@ -70,14 +71,20 @@ function SpellTabContent({
|
||||
|
||||
// Apply parameters to get final filter
|
||||
const appliedFilter = useMemo(() => {
|
||||
if (!parsed || !targetEventId) return null;
|
||||
if (!parsed || !targetEventId || !targetEvent) return null;
|
||||
|
||||
try {
|
||||
// For replaceable events, get the address coordinate (kind:pubkey:d-tag)
|
||||
// This ensures we reference them by address instead of event ID in filters
|
||||
const targetAddress = getEventCoordinate(targetEvent);
|
||||
|
||||
const applied = applySpellParameters(parsed, {
|
||||
targetEventId,
|
||||
targetAddress: targetAddress || undefined,
|
||||
});
|
||||
console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, {
|
||||
targetEventId,
|
||||
targetAddress,
|
||||
result: applied,
|
||||
});
|
||||
return applied;
|
||||
@@ -88,7 +95,7 @@ function SpellTabContent({
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [parsed, targetEventId, spell.name, spellId]);
|
||||
}, [parsed, targetEventId, targetEvent, spell.name, spellId]);
|
||||
|
||||
// Resolve relays - use explicit relays from spell, or use relay hints from target event
|
||||
const finalRelays = useMemo(() => {
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
encodeSpell,
|
||||
decodeSpell,
|
||||
applySpellParameters,
|
||||
getEventCoordinate,
|
||||
} from "./spell-conversion";
|
||||
import type { SpellEvent } from "@/types/spell";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
|
||||
describe("Spell Conversion", () => {
|
||||
@@ -1005,6 +1007,98 @@ describe("Spell Conversion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventCoordinate", () => {
|
||||
it("should return null for regular events", () => {
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 1234567890,
|
||||
kind: 1, // Regular kind
|
||||
tags: [],
|
||||
content: "Test",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return coordinate for simple replaceable events (kind 0)", () => {
|
||||
const pubkey = "a".repeat(64);
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey,
|
||||
created_at: 1234567890,
|
||||
kind: 0, // Metadata - simple replaceable
|
||||
tags: [],
|
||||
content: "{}",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBe(`0:${pubkey}:`);
|
||||
});
|
||||
|
||||
it("should return coordinate for simple replaceable events (kind 10002)", () => {
|
||||
const pubkey = "b".repeat(64);
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey,
|
||||
created_at: 1234567890,
|
||||
kind: 10002, // Relay list - simple replaceable
|
||||
tags: [],
|
||||
content: "",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBe(`10002:${pubkey}:`);
|
||||
});
|
||||
|
||||
it("should return coordinate for parameterized replaceable events with d-tag", () => {
|
||||
const pubkey = "c".repeat(64);
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey,
|
||||
created_at: 1234567890,
|
||||
kind: 30023, // Long-form content - parameterized replaceable
|
||||
tags: [["d", "my-article-slug"]],
|
||||
content: "Article content",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBe(
|
||||
`30023:${pubkey}:my-article-slug`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return coordinate with empty d-tag for parameterized replaceable without d-tag", () => {
|
||||
const pubkey = "d".repeat(64);
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey,
|
||||
created_at: 1234567890,
|
||||
kind: 30078, // App data - parameterized replaceable
|
||||
tags: [],
|
||||
content: "{}",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBe(`30078:${pubkey}:`);
|
||||
});
|
||||
|
||||
it("should return null for ephemeral events", () => {
|
||||
const event: NostrEvent = {
|
||||
id: "test123",
|
||||
pubkey: "e".repeat(64),
|
||||
created_at: 1234567890,
|
||||
kind: 20000, // Ephemeral
|
||||
tags: [],
|
||||
content: "",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(getEventCoordinate(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySpellParameters", () => {
|
||||
describe("$pubkey parameters", () => {
|
||||
it("should substitute $pubkey in authors array", () => {
|
||||
@@ -1109,6 +1203,96 @@ describe("Spell Conversion", () => {
|
||||
|
||||
expect(result.ids).toEqual([eventId]);
|
||||
});
|
||||
|
||||
it("should convert #e to #a tags when targetAddress is provided", () => {
|
||||
const parsed = {
|
||||
filter: { kinds: [1], "#e": ["$event"] },
|
||||
parameter: { type: "$event" as const },
|
||||
} as any;
|
||||
|
||||
const eventId = "abc123def456";
|
||||
const address = "30023:pubkeyhex:article-slug";
|
||||
const result = applySpellParameters(parsed, {
|
||||
targetEventId: eventId,
|
||||
targetAddress: address,
|
||||
});
|
||||
|
||||
// Should remove #e filter and add #a filter instead
|
||||
expect(result["#e"]).toBeUndefined();
|
||||
expect(result["#a"]).toEqual([address]);
|
||||
});
|
||||
|
||||
it("should preserve existing #a values when converting from #e", () => {
|
||||
const existingAddress = "30024:otherpubkey:other-article";
|
||||
const parsed = {
|
||||
filter: { kinds: [1], "#a": [existingAddress], "#e": ["$event"] },
|
||||
parameter: { type: "$event" as const },
|
||||
} as any;
|
||||
|
||||
const eventId = "abc123def456";
|
||||
const address = "30023:pubkeyhex:article-slug";
|
||||
const result = applySpellParameters(parsed, {
|
||||
targetEventId: eventId,
|
||||
targetAddress: address,
|
||||
});
|
||||
|
||||
// Should keep existing #a values and add new one
|
||||
expect(result["#e"]).toBeUndefined();
|
||||
expect(result["#a"]).toContain(existingAddress);
|
||||
expect(result["#a"]).toContain(address);
|
||||
});
|
||||
|
||||
it("should preserve non-$event values in #e when converting to #a", () => {
|
||||
const otherEventId = "otherevent123";
|
||||
const parsed = {
|
||||
filter: { kinds: [1], "#e": [otherEventId, "$event"] },
|
||||
parameter: { type: "$event" as const },
|
||||
} as any;
|
||||
|
||||
const eventId = "abc123def456";
|
||||
const address = "30023:pubkeyhex:article-slug";
|
||||
const result = applySpellParameters(parsed, {
|
||||
targetEventId: eventId,
|
||||
targetAddress: address,
|
||||
});
|
||||
|
||||
// Should keep non-$event values in #e and add $event to #a
|
||||
expect(result["#e"]).toEqual([otherEventId]);
|
||||
expect(result["#a"]).toEqual([address]);
|
||||
});
|
||||
|
||||
it("should use event ID in #e when no targetAddress provided", () => {
|
||||
const parsed = {
|
||||
filter: { kinds: [1], "#e": ["$event"] },
|
||||
parameter: { type: "$event" as const },
|
||||
} as any;
|
||||
|
||||
const eventId = "abc123def456";
|
||||
const result = applySpellParameters(parsed, {
|
||||
targetEventId: eventId,
|
||||
});
|
||||
|
||||
// Should use normal event ID substitution
|
||||
expect(result["#e"]).toEqual([eventId]);
|
||||
expect(result["#a"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use targetAddress in #a tags directly", () => {
|
||||
const parsed = {
|
||||
filter: { kinds: [1], "#a": ["$event"] },
|
||||
parameter: { type: "$event" as const },
|
||||
} as any;
|
||||
|
||||
const eventId = "abc123def456";
|
||||
const address = "30023:pubkeyhex:article-slug";
|
||||
const result = applySpellParameters(parsed, {
|
||||
targetEventId: eventId,
|
||||
targetAddress: address,
|
||||
});
|
||||
|
||||
// Should use address for #a tags
|
||||
expect(result["#a"]).toEqual([address]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("$relay parameters", () => {
|
||||
|
||||
@@ -6,7 +6,31 @@ import type {
|
||||
SpellEvent,
|
||||
} from "@/types/spell";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
import { isAddressableKind } from "./nostr-kinds";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Construct an address coordinate from an event if it's addressable
|
||||
* Returns coordinate in format: kind:pubkey:d-tag
|
||||
*
|
||||
* For replaceable events (10000-19999): kind:pubkey:
|
||||
* For parameterized replaceable events (30000-39999): kind:pubkey:d-tag
|
||||
*
|
||||
* @param event - Nostr event
|
||||
* @returns Coordinate string or null if not addressable
|
||||
*/
|
||||
export function getEventCoordinate(event: NostrEvent): string | null {
|
||||
if (!isAddressableKind(event.kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get d-tag value (empty string for simple replaceable events)
|
||||
const dTag = getTagValue(event, "d") || "";
|
||||
|
||||
return `${event.kind}:${event.pubkey}:${dTag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple tokenization that doesn't expand shell variables
|
||||
@@ -511,8 +535,12 @@ export function reconstructCommand(
|
||||
* - $contacts is replaced with targetContacts
|
||||
* - $pubkey is replaced with targetPubkey (for explicitly parameterized spells)
|
||||
*
|
||||
* For $event spells:
|
||||
* - If targetAddress is provided (replaceable events), uses #a tags instead of #e tags
|
||||
* - This ensures replaceable events are referenced by coordinate rather than ID
|
||||
*
|
||||
* @param parsed - Parsed spell
|
||||
* @param context - Context for substitution (pubkey, contacts, or event/relay IDs)
|
||||
* @param context - Context for substitution (pubkey, contacts, event ID/address, or relay)
|
||||
* @returns Filter with parameters applied
|
||||
*/
|
||||
export function applySpellParameters(
|
||||
@@ -521,6 +549,7 @@ export function applySpellParameters(
|
||||
targetPubkey?: string;
|
||||
targetContacts?: string[];
|
||||
targetEventId?: string;
|
||||
targetAddress?: string;
|
||||
targetRelay?: string;
|
||||
} = {},
|
||||
): NostrFilter {
|
||||
@@ -528,6 +557,7 @@ export function applySpellParameters(
|
||||
targetPubkey,
|
||||
targetContacts = [],
|
||||
targetEventId,
|
||||
targetAddress,
|
||||
targetRelay,
|
||||
} = context;
|
||||
|
||||
@@ -576,21 +606,64 @@ export function applySpellParameters(
|
||||
);
|
||||
}
|
||||
|
||||
// Substitute $event in ids
|
||||
// For replaceable events, we use address coordinates instead of event IDs in tags
|
||||
const useAddress = !!targetAddress;
|
||||
const addressValues = targetAddress ? [targetAddress] : [];
|
||||
|
||||
// Substitute $event in ids (always use event ID for direct lookups)
|
||||
if (filter.ids) {
|
||||
filter.ids = filter.ids.flatMap((id) =>
|
||||
id === "$event" ? values : [id],
|
||||
);
|
||||
}
|
||||
|
||||
// Substitute $event in all single-letter tag filters (#e, #a, etc.)
|
||||
// Substitute $event in all single-letter tag filters
|
||||
// For #e tags on replaceable events, convert to #a tags with address coordinate
|
||||
for (const key in filter) {
|
||||
if (key.startsWith("#") && key.length === 2) {
|
||||
const tagArray = filter[key as keyof NostrFilter] as string[];
|
||||
if (Array.isArray(tagArray)) {
|
||||
(filter as any)[key] = tagArray.flatMap((val) =>
|
||||
val === "$event" ? values : [val],
|
||||
// Check if this filter has $event placeholder
|
||||
const hasEventPlaceholder = tagArray.some(
|
||||
(val) => val === "$event",
|
||||
);
|
||||
|
||||
if (hasEventPlaceholder) {
|
||||
// Special handling for #e tags with replaceable events
|
||||
if (key === "#e" && useAddress) {
|
||||
// Move substitutions to #a tags for replaceable events
|
||||
const substituted = tagArray.flatMap(
|
||||
(val) => (val === "$event" ? [] : [val]), // Remove $event from #e
|
||||
);
|
||||
if (substituted.length > 0 || tagArray.length === 1) {
|
||||
// Only keep #e if it has non-placeholder values
|
||||
(filter as any)[key] =
|
||||
substituted.length > 0 ? substituted : undefined;
|
||||
}
|
||||
// Add to #a tags
|
||||
const existingA = (filter as any)["#a"] || [];
|
||||
(filter as any)["#a"] = [...existingA, ...addressValues];
|
||||
} else {
|
||||
// Normal substitution for other tags
|
||||
(filter as any)[key] = tagArray.flatMap((val) =>
|
||||
val === "$event"
|
||||
? useAddress && key === "#a"
|
||||
? addressValues
|
||||
: values
|
||||
: [val],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty tag filters
|
||||
for (const key in filter) {
|
||||
if (key.startsWith("#") && key.length === 2) {
|
||||
const tagArray = filter[key as keyof NostrFilter];
|
||||
if (Array.isArray(tagArray) && tagArray.length === 0) {
|
||||
delete (filter as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user