refactor: simplify spell parameter resolution to allow $me and $contacts together

Changed how non-parameterized spells with $me and $contacts are applied:
- $me now resolves to target pubkey
- $contacts now resolves to target pubkey's contacts
- Spells can now use both $me and $contacts in the same filter

Breaking change: applySpellParameters now uses context object instead of array
- Old: applySpellParameters(parsed, [pubkey])
- New: applySpellParameters(parsed, { targetPubkey, targetContacts, targetEventId, targetRelay })

- Simplified useParameterizedSpells to allow both $me and $contacts
- Added contact list fetching in ProfileViewer using kind 3 events
- Updated all viewers (Profile, Event, Relay) to use new API
- Updated all 87 tests in spell-conversion.test.ts
- All 1017 tests passing
This commit is contained in:
Claude
2026-01-22 16:31:34 +00:00
parent 3c4ff3e065
commit 99bca3f8df
6 changed files with 265 additions and 171 deletions

View File

@@ -129,9 +129,11 @@ function SpellTabContent({
if (!parsed || !targetEventId) return null;
try {
const applied = applySpellParameters(parsed, [targetEventId]);
const applied = applySpellParameters(parsed, {
targetEventId,
});
console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, {
input: targetEventId,
targetEventId,
result: applied,
});
return applied;

View File

@@ -70,6 +70,41 @@ function SpellTabContent({
targetPubkey,
}: SpellTabContentProps) {
const { state } = useGrimoire();
const eventStore = useEventStore();
// Fetch target pubkey's contacts (kind 3 contact list)
const targetContacts = useMemo(() => {
if (!targetPubkey) return [];
try {
const contactListEvent = eventStore.replaceable(
kinds.Contacts,
targetPubkey,
);
if (!contactListEvent) return [];
// Extract pubkeys from p tags
const contacts = contactListEvent.tags
.filter((tag) => tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
console.log(
`[SpellTabContent:${spell.name || spellId}] Target contacts:`,
{
count: contacts.length,
targetPubkey,
},
);
return contacts;
} catch (error) {
console.error(
`[SpellTabContent:${spell.name || spellId}] Failed to fetch contacts:`,
error,
);
return [];
}
}, [targetPubkey, eventStore, spell.name, spellId]);
// Parse spell and get filter - handle both published (with event) and local (command-only) spells
const parsed = useMemo(() => {
@@ -149,11 +184,15 @@ function SpellTabContent({
if (!parsed || !targetPubkey) return null;
try {
const applied = applySpellParameters(parsed, [targetPubkey]);
const applied = applySpellParameters(parsed, {
targetPubkey,
targetContacts,
});
console.log(
`[SpellTabContent:${spell.name || spellId}] Applied parameters:`,
{
input: targetPubkey,
targetPubkey,
targetContactsCount: targetContacts.length,
result: applied,
},
);
@@ -165,7 +204,7 @@ function SpellTabContent({
);
return null;
}
}, [parsed, targetPubkey, spell.name, spellId]);
}, [parsed, targetPubkey, targetContacts, spell.name, spellId]);
// Resolve relays - use explicit relays from spell, or use NIP-65 outbox selection
const fallbackRelays = useMemo(

View File

@@ -115,9 +115,11 @@ function SpellTabContent({
if (!parsed || !targetRelay) return null;
try {
const applied = applySpellParameters(parsed, [targetRelay]);
const applied = applySpellParameters(parsed, {
targetRelay,
});
console.log(`[RelaySpell:${spell.name || spellId}] Applied parameters:`, {
input: targetRelay,
targetRelay,
result: applied,
});
return applied;

View File

@@ -105,22 +105,10 @@ export function useParameterizedSpells(
// Include spells with $me or $contacts that could be parameterized
if (!spell.parameterType) {
const cmd = spell.command.toLowerCase();
// Check if command uses ONLY $me or ONLY $contacts (not both, not mixed with other pubkeys)
// Just check if command uses $me or $contacts - we'll resolve them at runtime
const hasMeOrContacts =
cmd.includes("$me") || cmd.includes("$contacts");
if (!hasMeOrContacts) return false;
// Make sure it doesn't use multiple different variables
const hasBothMeAndContacts =
cmd.includes("$me") && cmd.includes("$contacts");
if (hasBothMeAndContacts) return false; // Can't have both
// Check if it has hex pubkeys mixed with $me/$contacts
// This is a rough check - looking for hex strings in -a or #p tags
const hasHexPubkey = /(?:-a|#p)\s+[a-f0-9]{64}/.test(cmd);
if (hasHexPubkey) return false; // Mixed pubkeys, not eligible
return true;
return hasMeOrContacts;
}
return false;

View File

@@ -1014,24 +1014,11 @@ describe("Spell Conversion", () => {
} as any;
const hex = "a".repeat(64);
const result = applySpellParameters(parsed, [hex]);
const result = applySpellParameters(parsed, { targetPubkey: hex });
expect(result.authors).toEqual([hex]);
});
it("should substitute $pubkey with multiple values", () => {
const parsed = {
filter: { kinds: [1], authors: ["$pubkey"] },
parameter: { type: "$pubkey" as const },
} as any;
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = applySpellParameters(parsed, [hex1, hex2]);
expect(result.authors).toEqual([hex1, hex2]);
});
it("should substitute $pubkey in #p tag filters", () => {
const parsed = {
filter: { kinds: [1], "#p": ["$pubkey"] },
@@ -1039,7 +1026,7 @@ describe("Spell Conversion", () => {
} as any;
const hex = "c".repeat(64);
const result = applySpellParameters(parsed, [hex]);
const result = applySpellParameters(parsed, { targetPubkey: hex });
expect(result["#p"]).toEqual([hex]);
});
@@ -1051,7 +1038,7 @@ describe("Spell Conversion", () => {
} as any;
const hex = "d".repeat(64);
const result = applySpellParameters(parsed, [hex]);
const result = applySpellParameters(parsed, { targetPubkey: hex });
expect(result["#P"]).toEqual([hex]);
});
@@ -1064,19 +1051,19 @@ describe("Spell Conversion", () => {
parameter: { type: "$pubkey" as const },
} as any;
const result = applySpellParameters(parsed, [hex2]);
const result = applySpellParameters(parsed, { targetPubkey: hex2 });
expect(result.authors).toEqual([hex1, hex2]);
});
it("should use default values when no args provided", () => {
it("should use default values when no target provided", () => {
const hex = "a".repeat(64);
const parsed = {
filter: { kinds: [1], authors: ["$pubkey"] },
parameter: { type: "$pubkey" as const, default: [hex] },
} as any;
const result = applySpellParameters(parsed, []);
const result = applySpellParameters(parsed, {});
expect(result.authors).toEqual([hex]);
});
@@ -1090,7 +1077,9 @@ describe("Spell Conversion", () => {
} as any;
const eventId = "abc123def456";
const result = applySpellParameters(parsed, [eventId]);
const result = applySpellParameters(parsed, {
targetEventId: eventId,
});
expect(result["#e"]).toEqual([eventId]);
});
@@ -1102,7 +1091,7 @@ describe("Spell Conversion", () => {
} as any;
const addr = "30023:pubkey:article";
const result = applySpellParameters(parsed, [addr]);
const result = applySpellParameters(parsed, { targetEventId: addr });
expect(result["#a"]).toEqual([addr]);
});
@@ -1114,23 +1103,12 @@ describe("Spell Conversion", () => {
} as any;
const eventId = "abc123def456";
const result = applySpellParameters(parsed, [eventId]);
const result = applySpellParameters(parsed, {
targetEventId: eventId,
});
expect(result.ids).toEqual([eventId]);
});
it("should substitute $event with multiple values", () => {
const parsed = {
filter: { kinds: [1], "#e": ["$event"] },
parameter: { type: "$event" as const },
} as any;
const event1 = "abc123";
const event2 = "def456";
const result = applySpellParameters(parsed, [event1, event2]);
expect(result["#e"]).toEqual([event1, event2]);
});
});
describe("$relay parameters", () => {
@@ -1141,23 +1119,10 @@ describe("Spell Conversion", () => {
} as any;
const relay = "wss://relay.example.com/";
const result = applySpellParameters(parsed, [relay]);
const result = applySpellParameters(parsed, { targetRelay: relay });
expect(result["#r"]).toEqual([relay]);
});
it("should substitute $relay with multiple values", () => {
const parsed = {
filter: { kinds: [1], "#r": ["$relay"] },
parameter: { type: "$relay" as const },
} as any;
const relay1 = "wss://relay1.com/";
const relay2 = "wss://relay2.com/";
const result = applySpellParameters(parsed, [relay1, relay2]);
expect(result["#r"]).toEqual([relay1, relay2]);
});
});
describe("Edge cases", () => {
@@ -1166,7 +1131,9 @@ describe("Spell Conversion", () => {
filter: { kinds: [1], authors: ["abc123"] },
} as any;
const result = applySpellParameters(parsed, ["def456"]);
const result = applySpellParameters(parsed, {
targetPubkey: "def456",
});
expect(result).toEqual({ kinds: [1], authors: ["abc123"] });
});
@@ -1177,8 +1144,8 @@ describe("Spell Conversion", () => {
parameter: { type: "$pubkey" as const },
} as any;
expect(() => applySpellParameters(parsed, [])).toThrow(
"Parameterized spell requires $pubkey argument(s)",
expect(() => applySpellParameters(parsed, {})).toThrow(
"Parameterized $pubkey spell requires target pubkey",
);
});
@@ -1190,7 +1157,7 @@ describe("Spell Conversion", () => {
} as any;
const hex = "a".repeat(64);
applySpellParameters(parsed, [hex]);
applySpellParameters(parsed, { targetPubkey: hex });
// Original should be unchanged
expect(original.authors).toEqual(["$pubkey"]);
@@ -1203,7 +1170,7 @@ describe("Spell Conversion", () => {
} as any;
const hex = "a".repeat(64);
const result = applySpellParameters(parsed, [hex]);
const result = applySpellParameters(parsed, { targetPubkey: hex });
expect(result.authors).toEqual([]);
});
@@ -1215,12 +1182,86 @@ describe("Spell Conversion", () => {
} as any;
const hex = "a".repeat(64);
const result = applySpellParameters(parsed, [hex]);
const result = applySpellParameters(parsed, { targetPubkey: hex });
expect(result.authors).toBeUndefined();
expect(result.kinds).toEqual([1]);
});
});
describe("Implicit $me and $contacts resolution", () => {
it("should resolve $me to targetPubkey", () => {
const parsed = {
filter: { kinds: [1], authors: ["$me"] },
} as any;
const targetPubkey = "a".repeat(64);
const result = applySpellParameters(parsed, { targetPubkey });
expect(result.authors).toEqual([targetPubkey]);
});
it("should resolve $contacts to targetContacts array", () => {
const parsed = {
filter: { kinds: [1], authors: ["$contacts"] },
} as any;
const contact1 = "a".repeat(64);
const contact2 = "b".repeat(64);
const result = applySpellParameters(parsed, {
targetContacts: [contact1, contact2],
});
expect(result.authors).toEqual([contact1, contact2]);
});
it("should resolve both $me and $contacts in same filter", () => {
const parsed = {
filter: { kinds: [1], authors: ["$me", "$contacts"] },
} as any;
const targetPubkey = "a".repeat(64);
const contact1 = "b".repeat(64);
const contact2 = "c".repeat(64);
const result = applySpellParameters(parsed, {
targetPubkey,
targetContacts: [contact1, contact2],
});
expect(result.authors).toEqual([targetPubkey, contact1, contact2]);
});
it("should resolve $me and $contacts in #p tags", () => {
const parsed = {
filter: { kinds: [1], "#p": ["$me", "$contacts"] },
} as any;
const targetPubkey = "a".repeat(64);
const contact1 = "b".repeat(64);
const result = applySpellParameters(parsed, {
targetPubkey,
targetContacts: [contact1],
});
expect(result["#p"]).toEqual([targetPubkey, contact1]);
});
it("should preserve other values when resolving $me/$contacts", () => {
const otherPubkey = "z".repeat(64);
const parsed = {
filter: { kinds: [1], authors: [otherPubkey, "$me", "$contacts"] },
} as any;
const targetPubkey = "a".repeat(64);
const contact = "b".repeat(64);
const result = applySpellParameters(parsed, {
targetPubkey,
targetContacts: [contact],
});
expect(result.authors).toEqual([otherPubkey, targetPubkey, contact]);
});
});
});
describe("Round-trip with parameters", () => {
@@ -1284,11 +1325,11 @@ describe("Spell Conversion", () => {
// Filter should now have $pubkey placeholder
expect(decoded.filter.authors).toEqual(["$pubkey"]);
// Apply with arguments
const filter = applySpellParameters(decoded, [hex1, hex2]);
// Apply with single target pubkey
const filter = applySpellParameters(decoded, { targetPubkey: hex1 });
expect(filter.kinds).toEqual([1]);
expect(filter.authors).toEqual([hex1, hex2]);
expect(filter.authors).toEqual([hex1]);
});
it("should handle complex parameterized spell", () => {
@@ -1316,7 +1357,7 @@ describe("Spell Conversion", () => {
expect(decoded.filter.authors).toEqual(["$pubkey"]);
const hex = "c".repeat(64);
const filter = applySpellParameters(decoded, [hex]);
const filter = applySpellParameters(decoded, { targetPubkey: hex });
expect(filter.kinds).toEqual([1, 30023]);
expect(filter.authors).toEqual([hex]);

View File

@@ -506,115 +506,137 @@ export function reconstructCommand(
* Apply parameter values to a parameterized spell
* Substitutes parameter placeholders with actual values
*
* @param parsed - Parsed spell (must have parameter configuration)
* @param args - Arguments to substitute (if empty, uses defaults)
* For $pubkey spells:
* - $me is replaced with targetPubkey
* - $contacts is replaced with targetContacts
* - $pubkey is replaced with targetPubkey (for explicitly parameterized spells)
*
* @param parsed - Parsed spell
* @param context - Context for substitution (pubkey, contacts, or event/relay IDs)
* @returns Filter with parameters applied
*/
export function applySpellParameters(
parsed: Pick<ParsedSpell, "filter" | "parameter">,
args: string[] = [],
context: {
targetPubkey?: string;
targetContacts?: string[];
targetEventId?: string;
targetRelay?: string;
} = {},
): NostrFilter {
if (!parsed.parameter) {
// Not an explicitly parameterized spell
// Check if we have args and the filter uses $me or $contacts (implicit parameterization)
if (args.length > 0) {
const filter: NostrFilter = { ...parsed.filter };
// Substitute $me and $contacts with provided pubkey(s)
if (filter.authors) {
filter.authors = filter.authors.flatMap((author) =>
author === "$me" || author === "$contacts" ? args : [author],
);
}
if (filter["#p"]) {
filter["#p"] = filter["#p"].flatMap((p) =>
p === "$me" || p === "$contacts" ? args : [p],
);
}
if (filter["#P"]) {
filter["#P"] = filter["#P"].flatMap((p) =>
p === "$me" || p === "$contacts" ? args : [p],
);
}
return filter;
}
// No parameter and no args, return filter as-is
return parsed.filter;
}
// Use provided args or fall back to defaults
const values = args.length > 0 ? args : parsed.parameter.default || [];
if (values.length === 0) {
throw new Error(
`Parameterized spell requires ${parsed.parameter.type} argument(s)`,
);
}
const {
targetPubkey,
targetContacts = [],
targetEventId,
targetRelay,
} = context;
// Clone the filter
const filter: NostrFilter = { ...parsed.filter };
// Apply substitution based on parameter type
switch (parsed.parameter.type) {
case "$pubkey":
// Substitute in authors array
if (filter.authors) {
filter.authors = filter.authors.flatMap((author) =>
author === "$pubkey" ? values : [author],
);
// Handle explicitly parameterized spells
if (parsed.parameter) {
switch (parsed.parameter.type) {
case "$pubkey": {
const values = targetPubkey
? [targetPubkey]
: parsed.parameter.default || [];
if (values.length === 0) {
throw new Error("Parameterized $pubkey spell requires target pubkey");
}
// Substitute $pubkey placeholders
if (filter.authors) {
filter.authors = filter.authors.flatMap((author) =>
author === "$pubkey" ? values : [author],
);
}
if (filter["#p"]) {
filter["#p"] = filter["#p"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
}
if (filter["#P"]) {
filter["#P"] = filter["#P"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
}
break;
}
// Substitute in #p tag filters
if (filter["#p"]) {
filter["#p"] = filter["#p"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
case "$event": {
const values = targetEventId
? [targetEventId]
: parsed.parameter.default || [];
if (values.length === 0) {
throw new Error(
"Parameterized $event spell requires target event ID",
);
}
if (filter["#e"]) {
filter["#e"] = filter["#e"].flatMap((e) =>
e === "$event" ? values : [e],
);
}
if (filter["#a"]) {
filter["#a"] = filter["#a"].flatMap((a) =>
a === "$event" ? values : [a],
);
}
if (filter.ids) {
filter.ids = filter.ids.flatMap((id) =>
id === "$event" ? values : [id],
);
}
break;
}
// Substitute in #P tag filters
if (filter["#P"]) {
filter["#P"] = filter["#P"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
}
break;
case "$relay": {
const values = targetRelay
? [targetRelay]
: parsed.parameter.default || [];
if (values.length === 0) {
throw new Error("Parameterized $relay spell requires target relay");
}
case "$event":
// Substitute in #e tag filters
if (filter["#e"]) {
filter["#e"] = filter["#e"].flatMap((e) =>
e === "$event" ? values : [e],
);
if (filter["#r"]) {
filter["#r"] = filter["#r"].flatMap((r) =>
r === "$relay" ? values : [r],
);
}
break;
}
}
}
// Substitute in #a tag filters
if (filter["#a"]) {
filter["#a"] = filter["#a"].flatMap((a) =>
a === "$event" ? values : [a],
);
}
// Handle implicit parameterization ($me and $contacts)
// $me → targetPubkey
// $contacts → targetContacts
if (targetPubkey || targetContacts.length > 0) {
if (filter.authors) {
filter.authors = filter.authors.flatMap((author) => {
if (author === "$me") return targetPubkey ? [targetPubkey] : [];
if (author === "$contacts") return targetContacts;
return [author];
});
}
// Substitute in ids array
if (filter.ids) {
filter.ids = filter.ids.flatMap((id) =>
id === "$event" ? values : [id],
);
}
break;
if (filter["#p"]) {
filter["#p"] = filter["#p"].flatMap((p) => {
if (p === "$me") return targetPubkey ? [targetPubkey] : [];
if (p === "$contacts") return targetContacts;
return [p];
});
}
case "$relay":
// Relay parameters are handled differently
// They could affect relay hints or #r tag filters
if (filter["#r"]) {
filter["#r"] = filter["#r"].flatMap((r) =>
r === "$relay" ? values : [r],
);
}
break;
if (filter["#P"]) {
filter["#P"] = filter["#P"].flatMap((p) => {
if (p === "$me") return targetPubkey ? [targetPubkey] : [];
if (p === "$contacts") return targetContacts;
return [p];
});
}
}
return filter;