feat: spells

This commit is contained in:
Alejandro Gómez
2025-12-20 14:25:40 +01:00
parent a39dc658cd
commit 2987a37e65
47 changed files with 5590 additions and 169 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseCommandInput } from "./command-parser";
import { parseCommandInput, executeCommandParser } from "./command-parser";
/**
* Regression tests for parseCommandInput
@@ -254,3 +254,27 @@ describe("parseCommandInput - regression tests", () => {
});
});
});
describe("executeCommandParser - alias resolution", () => {
it("should resolve $me in profile command when activeAccountPubkey is provided", async () => {
const input = "profile $me";
const parsed = parseCommandInput(input);
const activeAccountPubkey =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const result = await executeCommandParser(parsed, activeAccountPubkey);
expect(result.error).toBeUndefined();
expect(result.props.pubkey).toBe(activeAccountPubkey);
});
it("should return $me literal in profile command when activeAccountPubkey is NOT provided", async () => {
const input = "profile $me";
const parsed = parseCommandInput(input);
const result = await executeCommandParser(parsed);
expect(result.error).toBeUndefined();
expect(result.props.pubkey).toBe("$me");
});
});

View File

@@ -97,6 +97,7 @@ export function parseCommandInput(input: string): ParsedCommand {
*/
export async function executeCommandParser(
parsed: ParsedCommand,
activeAccountPubkey?: string,
): Promise<ParsedCommand> {
if (!parsed.command) {
return parsed; // Already has error, return as-is
@@ -105,7 +106,9 @@ export async function executeCommandParser(
try {
// Use argParser if available, otherwise use defaultProps
const props = parsed.command.argParser
? await Promise.resolve(parsed.command.argParser(parsed.args))
? await Promise.resolve(
parsed.command.argParser(parsed.args, activeAccountPubkey),
)
: parsed.command.defaultProps || {};
return {
@@ -129,10 +132,11 @@ export async function executeCommandParser(
*/
export async function parseAndExecuteCommand(
input: string,
activeAccountPubkey?: string,
): Promise<ParsedCommand> {
const parsed = parseCommandInput(input);
if (parsed.error || !parsed.command) {
return parsed;
}
return executeCommandParser(parsed);
return executeCommandParser(parsed, activeAccountPubkey);
}

View File

@@ -24,7 +24,8 @@ const RESERVED_GLOBAL_FLAGS = ["--title"] as const;
*/
function sanitizeTitle(title: string): string | undefined {
const sanitized = title
.replace(/[\x00-\x1F\x7F]/g, "") // Strip control chars (newlines, tabs, null bytes)
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001F\u007F]/g, "") // Strip control chars (newlines, tabs, null bytes)
.trim();
if (!sanitized) {

View File

@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 9;
export const CURRENT_VERSION = 10;
/**
* Migration function type
@@ -105,6 +105,14 @@ const migrations: Record<number, MigrationFn> = {
__version: 9,
};
},
// Migration from v9 to v10 - adds compactModeKinds
9: (state: any) => {
return {
...state,
__version: 10,
compactModeKinds: [6, 7, 16, 9735],
};
},
};
/**
@@ -134,6 +142,11 @@ export function validateState(state: any): state is GrimoireState {
return false;
}
// compactModeKinds must be an array if present
if (state.compactModeKinds && !Array.isArray(state.compactModeKinds)) {
return false;
}
// Windows must be an object
if (typeof state.windows !== "object") {
return false;

View File

@@ -1,11 +1,61 @@
import type { ProfileContent } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { getNip10References } from "applesauce-core/helpers/threading";
import { getCommentReplyPointer } from "applesauce-core/helpers/comment";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
export function derivePlaceholderName(pubkey: string): string {
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
}
/**
* Get a reply pointer for an event, abstracting the differences between NIP-10 and NIP-22 (comments).
*/
export function getEventReply(
event: NostrEvent,
):
| { type: "root"; pointer: EventPointer | AddressPointer }
| { type: "reply"; pointer: EventPointer | AddressPointer }
| { type: "comment"; pointer: any }
| null {
// Handle Kind 1 (Text Note) - NIP-10
if (event.kind === 1) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
// Handle Kind 1111 (Comment) - NIP-22
if (event.kind === 1111) {
const pointer = getCommentReplyPointer(event);
if (pointer) {
return { type: "comment", pointer };
}
}
// Fallback for generic replies (using NIP-10 logic for other kinds usually works)
if (event.kind !== 1111) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
return null;
}
export function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && tag[1])

View File

@@ -14,9 +14,11 @@ export interface ParsedProfileCommand {
* - abc123... (64-char hex pubkey)
* - user@domain.com (NIP-05 identifier)
* - domain.com (bare domain, resolved as _@domain.com)
* - $me (active account alias)
*/
export async function parseProfileCommand(
args: string[],
activeAccountPubkey?: string,
): Promise<ParsedProfileCommand> {
const identifier = args[0];
@@ -24,6 +26,13 @@ export async function parseProfileCommand(
throw new Error("User identifier required");
}
// Handle $me alias
if (identifier.toLowerCase() === "$me") {
return {
pubkey: activeAccountPubkey || "$me",
};
}
// Try bech32 decode first (npub, nprofile)
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
try {

View File

@@ -179,7 +179,8 @@ describe("parseReqCommand", () => {
describe("event ID flag (-e) with nevent/naddr support", () => {
describe("nevent support", () => {
it("should parse nevent and populate filter.ids", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
});
@@ -192,7 +193,8 @@ describe("parseReqCommand", () => {
});
it("should extract relay hints from nevent", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -204,7 +206,8 @@ describe("parseReqCommand", () => {
});
it("should normalize relay URLs from nevent", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -218,7 +221,8 @@ describe("parseReqCommand", () => {
});
it("should handle nevent without relay hints", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
});
@@ -231,7 +235,8 @@ describe("parseReqCommand", () => {
describe("naddr support", () => {
it("should parse naddr and populate filter['#a']", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -245,7 +250,8 @@ describe("parseReqCommand", () => {
});
it("should extract relay hints from naddr", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -261,7 +267,8 @@ describe("parseReqCommand", () => {
});
it("should format coordinate correctly (kind:pubkey:identifier)", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -280,7 +287,8 @@ describe("parseReqCommand", () => {
});
it("should handle naddr without relay hints", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -295,7 +303,8 @@ describe("parseReqCommand", () => {
describe("note/hex support (existing behavior)", () => {
it("should parse note and populate filter['#e']", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const note = nip19.noteEncode(eventId);
const result = parseReqCommand(["-e", note]);
@@ -318,7 +327,8 @@ describe("parseReqCommand", () => {
describe("mixed format support", () => {
it("should handle comma-separated mix of all formats", () => {
const hex = "a".repeat(64);
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey = "b".repeat(64);
const note = nip19.noteEncode(eventId);
@@ -349,9 +359,13 @@ describe("parseReqCommand", () => {
});
it("should deduplicate within each filter field", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent1 = nip19.neventEncode({ id: eventId });
const nevent2 = nip19.neventEncode({ id: eventId, relays: ["wss://relay.damus.io"] });
const nevent2 = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
});
const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]);
@@ -360,7 +374,8 @@ describe("parseReqCommand", () => {
});
it("should collect relay hints from mixed formats", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey = "b".repeat(64);
const nevent = nip19.neventEncode({
@@ -383,7 +398,8 @@ describe("parseReqCommand", () => {
});
it("should handle multiple nevents with different relay hints", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent1 = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -438,7 +454,8 @@ describe("parseReqCommand", () => {
describe("integration with other flags", () => {
it("should work with kind filter", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({ id: eventId });
const result = parseReqCommand(["-k", "1", "-e", nevent]);
@@ -465,7 +482,8 @@ describe("parseReqCommand", () => {
it("should work with author and time filters", () => {
const hex = "c".repeat(64);
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({ id: eventId });
const result = parseReqCommand([
"-k",

View File

@@ -0,0 +1,768 @@
import { describe, it, expect } from "vitest";
import { encodeSpell, decodeSpell } from "./spell-conversion";
import type { SpellEvent } from "@/types/spell";
describe("Spell Conversion", () => {
describe("encodeSpell", () => {
it("should encode a simple REQ command with kinds", () => {
const result = encodeSpell({
command: "req -k 1,3,7",
description: "Test spell",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.tags).toContainEqual(["client", "grimoire"]);
expect(result.tags).toContainEqual(["k", "1"]);
expect(result.tags).toContainEqual(["k", "3"]);
expect(result.tags).toContainEqual(["k", "7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
expect(result.content).toBe("Test spell");
});
it("should encode with optional description", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Test spell",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.content).toBe("Test spell");
});
it("should encode with empty description", () => {
const result = encodeSpell({
command: "req -k 1",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.content).toBe("");
});
it("should encode optional name tag", () => {
const result = encodeSpell({
command: "req -k 1",
name: "Bitcoin Feed",
});
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
expect(result.tags).toContainEqual(["cmd", "REQ"]);
});
it("should skip name tag if not provided", () => {
const result = encodeSpell({
command: "req -k 1",
});
const nameTag = result.tags.find((t) => t[0] === "name");
expect(nameTag).toBeUndefined();
});
it("should trim and skip empty name", () => {
const result = encodeSpell({
command: "req -k 1",
name: " ",
});
const nameTag = result.tags.find((t) => t[0] === "name");
expect(nameTag).toBeUndefined();
});
it("should encode both name and description", () => {
const result = encodeSpell({
command: "req -k 1",
name: "Bitcoin Feed",
description: "Notes about Bitcoin",
});
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
expect(result.content).toBe("Notes about Bitcoin");
});
it("should encode authors as array tag", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = encodeSpell({
command: `req -k 1 -a ${hex1},${hex2}`,
description: "Author spell",
});
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", hex1, hex2]);
expect(result.filter.authors).toEqual([hex1, hex2]);
});
it("should encode limit, since, until", () => {
const result = encodeSpell({
command: "req -k 1 -l 50 --since 7d --until now",
description: "Time spell",
});
expect(result.tags).toContainEqual(["limit", "50"]);
expect(result.tags).toContainEqual(["since", "7d"]);
expect(result.tags).toContainEqual(["until", "now"]);
expect(result.filter.limit).toBe(50);
});
it("should encode tag filters with new format", () => {
const hex = "c".repeat(64);
const result = encodeSpell({
command: `req -k 1 -t bitcoin,nostr -p ${hex} -d article1`,
description: "Tag spell",
});
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
expect(result.tags).toContainEqual(["tag", "p", hex]);
expect(result.tags).toContainEqual(["tag", "d", "article1"]);
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(result.filter["#p"]).toEqual([hex]);
expect(result.filter["#d"]).toEqual(["article1"]);
});
it("should encode search query", () => {
const result = encodeSpell({
command: 'req -k 1 --search "bitcoin price"',
description: "Search spell",
});
expect(result.tags).toContainEqual(["search", "bitcoin price"]);
expect(result.filter.search).toBe("bitcoin price");
});
it("should encode relays", () => {
const result = encodeSpell({
command: "req -k 1 wss://relay1.com wss://relay2.com",
description: "Relay spell",
});
const relaysTag = result.tags.find((t) => t[0] === "relays");
expect(relaysTag).toEqual([
"relays",
"wss://relay1.com/",
"wss://relay2.com/",
]);
expect(result.relays).toEqual(["wss://relay1.com/", "wss://relay2.com/"]);
});
it("should encode close-on-eose flag", () => {
const result = encodeSpell({
command: "req -k 1 --close-on-eose",
description: "Close spell",
});
const closeTag = result.tags.find((t) => t[0] === "close-on-eose");
expect(closeTag).toBeDefined();
expect(result.closeOnEose).toBe(true);
});
it("should add topic tags", () => {
const result = encodeSpell({
command: "req -k 1",
description: "A test spell",
topics: ["bitcoin", "news"],
});
expect(result.tags).toContainEqual([
"alt",
"Grimoire REQ spell: A test spell",
]);
expect(result.tags).toContainEqual(["t", "bitcoin"]);
expect(result.tags).toContainEqual(["t", "news"]);
expect(result.content).toBe("A test spell");
});
it("should add fork provenance", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Forked spell",
forkedFrom: "abc123def456",
});
expect(result.tags).toContainEqual(["e", "abc123def456"]);
});
it("should handle special aliases $me and $contacts", () => {
const result = encodeSpell({
command: "req -k 1 -a $me,$contacts",
description: "Alias spell",
});
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
expect(result.filter.authors).toEqual(["$me", "$contacts"]);
});
it("should handle uppercase P tag separately from lowercase p", () => {
const hex1 = "d".repeat(64);
const hex2 = "e".repeat(64);
const result = encodeSpell({
command: `req -k 9735 -p ${hex1} -P ${hex2}`,
description: "P tag spell",
});
expect(result.tags).toContainEqual(["tag", "p", hex1]);
expect(result.tags).toContainEqual(["tag", "P", hex2]);
expect(result.filter["#p"]).toEqual([hex1]);
expect(result.filter["#P"]).toEqual([hex2]);
});
it("should handle generic tags with -T flag", () => {
const result = encodeSpell({
command: "req -k 1 -T x value1,value2",
description: "Generic tag spell",
});
expect(result.tags).toContainEqual(["tag", "x", "value1", "value2"]);
expect(result.filter["#x"]).toEqual(["value1", "value2"]);
});
it("should handle complex command with multiple filters", () => {
const hex1 = "f".repeat(64);
const hex2 = "0".repeat(64);
const result = encodeSpell({
command: `req -k 1,3,30023 -a ${hex1},${hex2} -l 100 -t bitcoin,nostr --since 7d --search crypto wss://relay.com --close-on-eose`,
description: "Multi-filter spell",
topics: ["test"],
});
// Verify all components are present
expect(result.tags).toContainEqual(["k", "1"]);
expect(result.tags).toContainEqual(["k", "3"]);
expect(result.tags).toContainEqual(["k", "30023"]);
expect(result.tags).toContainEqual(["authors", hex1, hex2]);
expect(result.tags).toContainEqual(["limit", "100"]);
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
expect(result.tags).toContainEqual(["since", "7d"]);
expect(result.tags).toContainEqual(["search", "crypto"]);
expect(result.tags).toContainEqual(["relays", "wss://relay.com/"]);
expect(result.tags).toContainEqual(["close-on-eose", ""]);
expect(result.tags).toContainEqual(["t", "test"]);
// Verify filter
expect(result.filter.kinds).toEqual([1, 3, 30023]);
expect(result.filter.authors).toEqual([hex1, hex2]);
expect(result.filter.limit).toBe(100);
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(result.filter.search).toBe("crypto");
});
});
describe("decodeSpell", () => {
it("should decode a simple spell back to command", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["k", "3"],
],
content: "Test spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("Test spell");
expect(parsed.filter.kinds).toEqual([1, 3]);
expect(parsed.command).toContain("-k 1,3");
});
it("should decode description from content", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "Test spell description",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("Test spell description");
expect(parsed.filter.kinds).toEqual([1]);
});
it("should handle empty content", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBeUndefined();
expect(parsed.filter.kinds).toEqual([1]);
});
it("should decode name from tags", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["name", "Bitcoin Feed"],
["k", "1"],
],
content: "Notes about Bitcoin",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.name).toBe("Bitcoin Feed");
expect(parsed.description).toBe("Notes about Bitcoin");
expect(parsed.filter.kinds).toEqual([1]);
});
it("should handle missing name tag", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "Test",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.name).toBeUndefined();
expect(parsed.description).toBe("Test");
});
it("should decode authors", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["authors", "abc123", "def456"],
],
content: "Author spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.filter.authors).toEqual(["abc123", "def456"]);
expect(parsed.command).toContain("-a abc123,def456");
});
it("should decode tag filters with new format", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["tag", "t", "bitcoin", "nostr"],
["tag", "p", "abc123"],
["tag", "P", "def456"],
],
content: "Tag spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(parsed.filter["#p"]).toEqual(["abc123"]);
expect(parsed.filter["#P"]).toEqual(["def456"]);
expect(parsed.command).toContain("-t bitcoin,nostr");
expect(parsed.command).toContain("-p abc123");
expect(parsed.command).toContain("-P def456");
});
it("should decode time bounds with relative format", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["since", "7d"],
["until", "now"],
],
content: "Time spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.command).toContain("--since 7d");
expect(parsed.command).toContain("--until now");
});
it("should decode topics", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["t", "bitcoin"],
["t", "news"],
],
content: "A test spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("A test spell");
expect(parsed.topics).toEqual(["bitcoin", "news"]);
});
it("should decode fork provenance", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["e", "abc123def456"],
],
content: "Forked spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.forkedFrom).toBe("abc123def456");
});
it("should throw error if cmd is not REQ", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [["cmd", "INVALID"]],
content: "Test",
sig: "test-sig",
};
expect(() => decodeSpell(event)).toThrow(
"Invalid spell command type: INVALID",
);
});
});
describe("Round-trip conversion", () => {
it("should preserve filter semantics through encode → decode", () => {
const hex1 = "1".repeat(64);
const hex2 = "2".repeat(64);
const original = {
command: `req -k 1,3,7 -a ${hex1},${hex2} -l 50 -t bitcoin,nostr --since 7d --search crypto`,
description: "Testing round-trip conversion",
topics: ["test"],
};
// Encode
const encoded = encodeSpell(original);
// Create event
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
// Decode
const decoded = decodeSpell(event);
// Verify filter semantics are preserved
expect(decoded.filter.kinds).toEqual([1, 3, 7]);
expect(decoded.filter.authors).toEqual([hex1, hex2]);
expect(decoded.filter.limit).toBe(50);
expect(decoded.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(decoded.filter.search).toBe("crypto");
// Verify metadata
expect(decoded.description).toBe("Testing round-trip conversion");
expect(decoded.topics).toEqual(["test"]);
// Verify command contains key components (order may differ)
expect(decoded.command).toContain("-k 1,3,7");
expect(decoded.command).toContain(`-a ${hex1},${hex2}`);
expect(decoded.command).toContain("-l 50");
expect(decoded.command).toContain("-t bitcoin,nostr");
expect(decoded.command).toContain("--since 7d");
expect(decoded.command).toContain("--search");
});
it("should handle minimal spell without description", () => {
const original = {
command: "req -k 1",
};
const encoded = encodeSpell(original);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.description).toBeUndefined();
expect(decoded.filter.kinds).toEqual([1]);
expect(decoded.command).toBe("req -k 1");
});
it("should round-trip with name", () => {
const original = {
command: "req -k 1",
name: "Bitcoin Feed",
description: "Notes about Bitcoin",
};
const encoded = encodeSpell(original);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.name).toBe("Bitcoin Feed");
expect(decoded.description).toBe("Notes about Bitcoin");
expect(decoded.filter.kinds).toEqual([1]);
});
it("should preserve special aliases through round-trip", () => {
const original = {
command: "req -k 1 -a $me,$contacts -p $me",
description: "Alias spell",
};
const encoded = encodeSpell(original);
// Debug: Check what was encoded
const authorsTag = encoded.tags.find((t) => t[0] === "authors");
const pTag = encoded.tags.find((t) => t[0] === "tag" && t[1] === "p");
// Verify encoding worked
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
expect(pTag).toEqual(["tag", "p", "$me"]);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.filter.authors).toEqual(["$me", "$contacts"]);
expect(decoded.filter["#p"]).toEqual(["$me"]);
expect(decoded.command).toContain("-a $me,$contacts");
expect(decoded.command).toContain("-p $me");
});
});
describe("Validation and edge cases", () => {
it("should throw error for empty command", () => {
expect(() =>
encodeSpell({
command: "",
}),
).toThrow("Spell command is required");
});
it("should throw error for whitespace-only command", () => {
expect(() =>
encodeSpell({
command: " ",
}),
).toThrow("Spell command is required");
});
it("should throw error for 'req' with no filters", () => {
expect(() =>
encodeSpell({
command: "req",
}),
).toThrow(); // Will throw either empty tokens or no constraints error
});
it("should throw error for command with no valid filters", () => {
expect(() =>
encodeSpell({
command: "req --invalid-flag",
}),
).toThrow(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
});
it("should throw error for command with only invalid values", () => {
expect(() =>
encodeSpell({
command: "req -k invalid",
}),
).toThrow(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
});
it("should handle malformed author values gracefully", () => {
// Invalid hex should be ignored, not cause errors
const result = encodeSpell({
command: "req -k 1 -a invalid",
});
// Should have kinds but no authors
expect(result.filter.kinds).toEqual([1]);
expect(result.filter.authors).toBeUndefined();
});
it("should handle mixed valid and invalid values", () => {
const hex = "a".repeat(64);
const result = encodeSpell({
command: `req -k 1,invalid,3 -a ${hex},invalid`,
});
// Should keep only valid values
expect(result.filter.kinds).toEqual([1, 3]);
expect(result.filter.authors).toEqual([hex]);
});
it("should handle quotes in search query", () => {
const result = encodeSpell({
command: 'req -k 1 --search "quoted text"',
});
expect(result.filter.search).toBe("quoted text");
});
it("should handle single quotes in search query", () => {
const result = encodeSpell({
command: "req -k 1 --search 'single quoted'",
});
expect(result.filter.search).toBe("single quoted");
});
it("should handle special characters in search", () => {
const result = encodeSpell({
command: "req -k 1 --search 'text with #hashtag @mention'",
});
expect(result.filter.search).toBe("text with #hashtag @mention");
});
it("should accept commands with only limit", () => {
const result = encodeSpell({
command: "req -l 50",
});
expect(result.filter.limit).toBe(50);
});
it("should accept commands with only time bounds", () => {
const result = encodeSpell({
command: "req --since 7d",
});
expect(result.filter.since).toBeDefined();
});
it("should accept commands with only search", () => {
const result = encodeSpell({
command: "req --search bitcoin",
});
expect(result.filter.search).toBe("bitcoin");
});
it("should handle very long commands", () => {
// Generate 10 different hex values to test long author lists
const hexValues = Array(10)
.fill(0)
.map((_, i) => i.toString(16).repeat(64).slice(0, 64))
.join(",");
const result = encodeSpell({
command: `req -k 1 -a ${hexValues}`,
});
expect(result.filter.authors).toBeDefined();
expect(result.filter.authors!.length).toBe(10);
});
it("should handle Unicode in descriptions", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Testing with emoji 🎨 and unicode 你好",
});
expect(result.content).toBe("Testing with emoji 🎨 and unicode 你好");
});
it("should preserve exact capitalization in $me/$contacts", () => {
const result = encodeSpell({
command: "req -k 1 -a $Me,$CONTACTS",
});
// Parser normalizes to lowercase
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
});
});
});

439
src/lib/spell-conversion.ts Normal file
View File

@@ -0,0 +1,439 @@
import { parseReqCommand } from "./req-parser";
import type {
CreateSpellOptions,
EncodedSpell,
ParsedSpell,
SpellEvent,
} from "@/types/spell";
import type { NostrFilter } from "@/types/nostr";
/**
* Simple tokenization that doesn't expand shell variables
* Splits on whitespace while respecting quoted strings
*/
function tokenizeCommand(command: string): string[] {
const tokens: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < command.length; i++) {
const char = command[i];
if ((char === '"' || char === "'") && !inQuotes) {
// Start quoted string
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
// End quoted string
inQuotes = false;
quoteChar = "";
} else if (char === " " && !inQuotes) {
// Whitespace outside quotes - end token
if (current) {
tokens.push(current);
current = "";
}
} else {
// Regular character
current += char;
}
}
// Add final token
if (current) {
tokens.push(current);
}
return tokens;
}
/**
* Encode a REQ command as spell event tags
*
* Parses the command and extracts filter parameters into Nostr tags.
* Preserves relative timestamps (7d, now) for dynamic spell behavior.
*
* @param options - Spell creation options with command string
* @returns Encoded spell with tags, content, and parsed filter
* @throws Error if command is invalid or produces empty filter
*/
export function encodeSpell(options: CreateSpellOptions): EncodedSpell {
const { command, name, description, topics, forkedFrom } = options;
// Validate command
if (!command || command.trim().length === 0) {
throw new Error("Spell command is required");
}
// Parse the command to extract filter components
// Remove "req" prefix if present and tokenize
const commandWithoutReq = command.replace(/^\s*req\s+/, "");
const tokens = tokenizeCommand(commandWithoutReq);
// Validate we have tokens to parse
if (tokens.length === 0) {
throw new Error("Spell command must contain filters or parameters");
}
const parsed = parseReqCommand(tokens);
// Validate that parsing produced a useful filter
// A filter must have at least one constraint
const hasConstraints =
(parsed.filter.kinds && parsed.filter.kinds.length > 0) ||
(parsed.filter.authors && parsed.filter.authors.length > 0) ||
(parsed.filter.ids && parsed.filter.ids.length > 0) ||
parsed.filter.limit !== undefined ||
parsed.filter.since !== undefined ||
parsed.filter.until !== undefined ||
parsed.filter.search !== undefined ||
Object.keys(parsed.filter).some((k) => k.startsWith("#"));
if (!hasConstraints) {
throw new Error(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
}
// Start with required tags
const tags: [string, string, ...string[]][] = [
["cmd", "REQ"],
["client", "grimoire"],
];
// Add name tag if provided
if (name && name.trim().length > 0) {
tags.push(["name", name.trim()]);
}
// Add alt tag for NIP-31 compatibility
const altText = description
? `Grimoire REQ spell: ${description.substring(0, 100)}`
: "Grimoire REQ spell";
tags.push(["alt", altText]);
// Add provenance if forked
if (forkedFrom) {
tags.push(["e", forkedFrom]);
}
// Encode filter.kinds as multiple k tags for queryability
if (parsed.filter.kinds) {
for (const kind of parsed.filter.kinds) {
tags.push(["k", kind.toString()]);
}
}
// Encode filter.authors as single array tag
if (parsed.filter.authors && parsed.filter.authors.length > 0) {
tags.push(["authors", ...parsed.filter.authors] as [
string,
string,
...string[],
]);
}
// Encode filter.ids as single array tag
if (parsed.filter.ids && parsed.filter.ids.length > 0) {
tags.push(["ids", ...parsed.filter.ids] as [string, string, ...string[]]);
}
// Encode tag filters (#e, #p, #P, #t, #d, #a, and any generic tags)
// New format: ["tag", "letter", ...values]
const tagFilters: Record<string, string[]> = {};
// Collect all # tags from filter
for (const [key, value] of Object.entries(parsed.filter)) {
if (key.startsWith("#") && Array.isArray(value)) {
tagFilters[key] = value as string[];
}
}
// Add tag filter tags with new format
for (const [tagName, values] of Object.entries(tagFilters)) {
if (values.length > 0) {
// Extract the letter from #letter format
const letter = tagName.substring(1); // Remove the # prefix
tags.push(["tag", letter, ...values] as [string, string, ...string[]]);
}
}
// Encode scalars
if (parsed.filter.limit !== undefined) {
tags.push(["limit", parsed.filter.limit.toString()]);
}
// For timestamps, we need to preserve the original format if it was relative
// The parser converts everything to unix timestamps, losing this info
// We'll need to detect relative times in the original command
// This is a limitation - for MVP, we'll store the resolved timestamps
// TODO: Enhance parser to preserve original time format
if (parsed.filter.since !== undefined) {
// Try to extract original since value from command
const sinceMatch = command.match(/--since\s+(\S+)/);
if (sinceMatch && sinceMatch[1]) {
tags.push(["since", sinceMatch[1]]);
} else {
tags.push(["since", parsed.filter.since.toString()]);
}
}
if (parsed.filter.until !== undefined) {
// Try to extract original until value from command
const untilMatch = command.match(/--until\s+(\S+)/);
if (untilMatch && untilMatch[1]) {
tags.push(["until", untilMatch[1]]);
} else {
tags.push(["until", parsed.filter.until.toString()]);
}
}
if (parsed.filter.search) {
tags.push(["search", parsed.filter.search]);
}
// Add relays if specified
if (parsed.relays && parsed.relays.length > 0) {
tags.push(["relays", ...parsed.relays] as [string, string, ...string[]]);
}
// Add close-on-eose flag if set
if (parsed.closeOnEose) {
tags.push(["close-on-eose", ""] as [string, string, ...string[]]);
}
// Add topic tags for categorization
if (topics && topics.length > 0) {
for (const topic of topics) {
tags.push(["t", topic]);
}
}
// Content is the description (or empty if not provided)
const content = description || "";
return {
tags,
content,
filter: parsed.filter,
relays: parsed.relays,
closeOnEose: parsed.closeOnEose || false,
};
}
/**
* Decode a spell event back to a REQ command string
*
* Reconstructs a canonical REQ command from the spell's tags.
* The reconstructed command may differ in formatting from the original
* but produces an equivalent Nostr filter.
*
* @param event - Spell event (kind 777)
* @returns Parsed spell with reconstructed command
*/
export function decodeSpell(event: SpellEvent): ParsedSpell {
// Extract tags into a map for easier access
const tagMap = new Map<string, string[]>();
for (const tag of event.tags) {
const [name, ...values] = tag;
if (!tagMap.has(name)) {
tagMap.set(name, []);
}
tagMap.get(name)!.push(...values);
}
// Validate cmd tag
const cmd = tagMap.get("cmd")?.[0];
if (cmd !== "REQ") {
throw new Error(`Invalid spell command type: ${cmd}`);
}
// Extract metadata
const name = tagMap.get("name")?.[0];
const description = event.content || undefined;
const topics = tagMap.get("t") || [];
const forkedFrom = tagMap.get("e")?.[0];
// Reconstruct filter from tags
const filter: NostrFilter = {};
// Kinds
const kinds = tagMap.get("k");
if (kinds && kinds.length > 0) {
filter.kinds = kinds.map((k) => parseInt(k, 10)).filter((k) => !isNaN(k));
}
// Authors
const authors = tagMap.get("authors");
if (authors && authors.length > 0) {
filter.authors = authors;
}
// IDs
const ids = tagMap.get("ids");
if (ids && ids.length > 0) {
filter.ids = ids;
}
// Tag filters - new format: ["tag", "letter", ...values]
// Parse all "tag" tags and convert to filter[#letter] format
const tagFilterTags = event.tags.filter((t) => t[0] === "tag");
for (const tag of tagFilterTags) {
const [, letter, ...values] = tag;
if (letter && values.length > 0) {
(filter as any)[`#${letter}`] = values;
}
}
// Scalars
const limit = tagMap.get("limit")?.[0];
if (limit) {
filter.limit = parseInt(limit, 10);
}
const since = tagMap.get("since")?.[0];
if (since) {
// Check if it's a relative time or unix timestamp
if (/^\d{10}$/.test(since)) {
filter.since = parseInt(since, 10);
} else {
// It's a relative time format - preserve it as a comment
// For actual filtering, we'd need to resolve it at runtime
// For now, skip adding to filter (will be resolved at execution)
}
}
const until = tagMap.get("until")?.[0];
if (until) {
// Check if it's a relative time or unix timestamp
if (/^\d{10}$/.test(until)) {
filter.until = parseInt(until, 10);
} else {
// It's a relative time format - preserve it as a comment
// For now, skip adding to filter (will be resolved at execution)
}
}
const search = tagMap.get("search")?.[0];
if (search) {
filter.search = search;
}
// Options
const relays = tagMap.get("relays");
const closeOnEose = tagMap.has("close-on-eose");
// Reconstruct command string
const command = reconstructCommand(filter, relays, since, until, closeOnEose);
return {
name,
description,
command,
filter,
relays,
closeOnEose,
topics,
forkedFrom,
event,
};
}
/**
* Reconstruct a canonical REQ command string from filter components
*/
export function reconstructCommand(
filter: NostrFilter,
relays?: string[],
since?: string,
until?: string,
closeOnEose?: boolean,
): string {
const parts: string[] = ["req"];
// Kinds
if (filter.kinds && filter.kinds.length > 0) {
parts.push(`-k ${filter.kinds.join(",")}`);
}
// Authors
if (filter.authors && filter.authors.length > 0) {
parts.push(`-a ${filter.authors.join(",")}`);
}
// Limit
if (filter.limit !== undefined) {
parts.push(`-l ${filter.limit}`);
}
// IDs (use -e flag, though semantics differ slightly)
if (filter.ids && filter.ids.length > 0) {
parts.push(`-e ${filter.ids.join(",")}`);
}
// Tag filters
if (filter["#e"] && filter["#e"].length > 0) {
parts.push(`-e ${filter["#e"].join(",")}`);
}
if (filter["#p"] && filter["#p"].length > 0) {
parts.push(`-p ${filter["#p"].join(",")}`);
}
if (filter["#P"] && filter["#P"].length > 0) {
parts.push(`-P ${filter["#P"].join(",")}`);
}
if (filter["#t"] && filter["#t"].length > 0) {
parts.push(`-t ${filter["#t"].join(",")}`);
}
if (filter["#d"] && filter["#d"].length > 0) {
parts.push(`-d ${filter["#d"].join(",")}`);
}
if (filter["#a"] && filter["#a"].length > 0) {
// Note: #a filters came from naddr, but we reconstruct as comma-separated
parts.push(`-e ${filter["#a"].join(",")}`);
}
// Generic single-letter tags
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith("#") && key.length === 2 && Array.isArray(value)) {
const letter = key[1];
// Skip already handled tags
if (!["e", "p", "P", "t", "d", "a"].includes(letter)) {
parts.push(`-T ${letter} ${(value as string[]).join(",")}`);
}
}
}
// Time bounds (preserve relative format if available)
if (since) {
parts.push(`--since ${since}`);
}
if (until) {
parts.push(`--until ${until}`);
}
// Search
if (filter.search) {
parts.push(`--search "${filter.search}"`);
}
// Relays
if (relays && relays.length > 0) {
parts.push(...relays);
}
// Close on EOSE
if (closeOnEose) {
parts.push("--close-on-eose");
}
return parts.join(" ");
}