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