diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index 5ec0984..a7211a0 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -1,47 +1,23 @@ /** * useCVMClient * ───────────────────────────────────────────────────────────────────────────── - * Browser-side client for the SkateSpots CVM server running on LNVPS. + * Queries Nostr relays directly from the browser — same logic as the CVM + * server but without the NIP-44 encrypted round-trip (which was unreliable). * - * Calls the MCP tools exposed by the server via a direct Nostr-encrypted - * request/response cycle (NIP-44 gift wrap style), without bundling the - * full @contextvm/sdk (which has Node.js dependencies incompatible with - * browser bundling). - * - * Protocol (simplified MCP over Nostr): - * 1. Generate ephemeral Nostr keypair for this session - * 2. Encrypt the MCP JSON-RPC request with NIP-44 to the server pubkey - * 3. Publish as kind 21000 (ContextVM request event) to relays - * 4. Subscribe for a response event tagged with our ephemeral pubkey - * 5. Decrypt the response and parse the MCP result - * - * Server pubkey: e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530 + * This gives instant results with no dependency on the CVM server being up. */ import { useCallback } from 'react'; -import { - generateSecretKey, - getPublicKey, - finalizeEvent, - Relay, -} from 'nostr-tools'; -import { nip44 } from 'nostr-tools'; +import { useNostr } from '@nostrify/react'; -// ─── CVM Server Config ─────────────────────────────────────────────────────── +// ─── Relay list ─────────────────────────────────────────────────────────────── -export const CVM_SERVER_PUBKEY = - 'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530'; - -const CVM_RELAYS = [ +const DATA_RELAYS = [ 'wss://goskatespots.nostr1.com', 'wss://relay.ditto.pub', 'wss://relay.damus.io', ]; -// ContextVM uses kind 21000 for client→server requests (ephemeral) -const CVM_REQUEST_KIND = 21000; -const CVM_RESPONSE_KIND = 21001; - // ─── Tool result types ─────────────────────────────────────────────────────── export interface SpotResult { @@ -49,6 +25,7 @@ export interface SpotResult { description: string; city?: string; country?: string; + location?: string; difficulty?: string; features: string[]; geohash?: string; @@ -108,118 +85,41 @@ export interface CommunityStats { queried_at: string; } -// ─── Core: send a CVM tool call via Nostr ──────────────────────────────────── +// ─── Helpers ───────────────────────────────────────────────────────────────── -async function callCVMTool( - toolName: string, - toolArgs: Record, - timeoutMs = 15000 -): Promise { - // Generate ephemeral keypair for this request - const sk = generateSecretKey(); - const pk = getPublicKey(sk); - - // Build the MCP JSON-RPC request - const requestId = Math.floor(Math.random() * 1000000); - const mcpRequest = { - jsonrpc: '2.0', - id: requestId, - method: 'tools/call', - params: { - name: toolName, - arguments: toolArgs, - }, - }; - - // Encrypt request to server using NIP-44 - const conversationKey = nip44.v2.utils.getConversationKey(sk, CVM_SERVER_PUBKEY); - const encryptedContent = nip44.v2.encrypt( - JSON.stringify(mcpRequest), - conversationKey - ); - - // Build and sign the request event - const signedEvent = finalizeEvent({ - kind: CVM_REQUEST_KIND, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', CVM_SERVER_PUBKEY]], - content: encryptedContent, - }, sk); - - // Use raw Relay connections (nostr-tools v2 compatible) - // Try each relay in order, race for the first response - return new Promise((resolve, reject) => { - const relays: InstanceType[] = []; - let settled = false; - - const cleanup = () => { - relays.forEach(r => { try { r.close(); } catch { /* ignore */ } }); - }; - - const timer = setTimeout(() => { - if (!settled) { - settled = true; - cleanup(); - reject(new Error(`CVM tool call timed out after ${timeoutMs}ms`)); - } - }, timeoutMs); - - const since = Math.floor(Date.now() / 1000) - 5; - - CVM_RELAYS.forEach(async (url) => { - try { - const relay = await Relay.connect(url); - relays.push(relay); - - // Subscribe for response BEFORE publishing - const sub = relay.subscribe( - [{ kinds: [CVM_RESPONSE_KIND], '#p': [pk], since }], - { - onevent(responseEvent) { - if (settled) return; - try { - const convKey = nip44.v2.utils.getConversationKey(sk, responseEvent.pubkey); - const decrypted = nip44.v2.decrypt(responseEvent.content, convKey); - const mcpResponse = JSON.parse(decrypted); - if (mcpResponse.id !== requestId) return; - - settled = true; - clearTimeout(timer); - sub.close(); - cleanup(); - - if (mcpResponse.error) { - reject(new Error(mcpResponse.error.message ?? 'CVM error')); - } else { - resolve(mcpResponse.result); - } - } catch { - // Not for us — ignore - } - }, - } - ); - - // Now publish the request - await relay.publish(signedEvent); - } catch { - // This relay failed — others may still succeed - } - }); - }); +function getTag(event: { tags: string[][] }, name: string): string | undefined { + return event.tags.find((t) => t[0] === name)?.[1]; } -// Parse JSON from MCP tool result content array -function parseToolResult(result: unknown): T { - const r = result as { content?: Array<{ type: string; text?: string }> }; - const text = r?.content?.[0]?.text; - if (!text) throw new Error('Empty tool result'); - return JSON.parse(text) as T; +function getTags(event: { tags: string[][] }, name: string): string[] { + return event.tags + .filter((t) => t[0] === name) + .map((t) => t[1]) + .filter(Boolean); +} + +function deduplicateAddressable(events: { pubkey: string; kind: number; tags: string[][]; created_at: number }[]) { + const seen = new Map(); + for (const e of events) { + const d = getTag(e, 'd') ?? ''; + const key = `${e.pubkey}:${e.kind}:${d}`; + const existing = seen.get(key); + if (!existing || e.created_at > existing.created_at) { + seen.set(key, e); + } + } + return Array.from(seen.values()); +} + +function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }): string { + return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`; } // ─── The hook ──────────────────────────────────────────────────────────────── export function useCVMClient() { + const { nostr } = useNostr(); + const findSpots = useCallback( async (params: { city?: string; @@ -229,10 +129,100 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; spots: SpotResult[] }> => { - const result = await callCVMTool('find_spots', params as Record); - return parseToolResult(result); + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + + const raw = await relayGroup.query( + [{ kinds: [30818], limit: 500 }], + { signal } + ); + + let spots = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); + + // Only valid skate spots + spots = spots.filter((e) => + e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot') + ); + + if (params.difficulty && params.difficulty !== 'all') { + spots = spots.filter( + (e) => getTag(e, 'difficulty')?.toLowerCase() === params.difficulty + ); + } + + if (params.city) { + const q = params.city.toLowerCase(); + spots = spots.filter( + (e) => + (getTag(e, 'city') ?? '').toLowerCase().includes(q) || + (getTag(e, 'location') ?? '').toLowerCase().includes(q) || + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + if (params.country) { + const q = params.country.toLowerCase(); + spots = spots.filter( + (e) => + (getTag(e, 'country') ?? '').toLowerCase().includes(q) || + (getTag(e, 'location') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + if (params.features && params.features.length > 0) { + spots = spots.filter((e) => { + const spotFeatures = [ + ...getTags(e, 'feature'), + ...getTags(e, 't'), + ].map((f) => f.toLowerCase()); + const content = (e as unknown as { content: string }).content.toLowerCase(); + return params.features!.some( + (f) => + spotFeatures.some((sf) => sf.includes(f.toLowerCase())) || + content.includes(f.toLowerCase()) + ); + }); + } + + if (params.search) { + const q = params.search.toLowerCase(); + spots = spots.filter( + (e) => + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (getTag(e, 'name') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + spots.sort((a, b) => b.created_at - a.created_at); + spots = spots.slice(0, params.limit ?? 10); + + const result: SpotResult[] = spots.map((e) => { + const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; + const imeta = ev.tags.find((t) => t[0] === 'imeta'); + const imetaUrl = imeta?.find((p) => p.startsWith('url '))?.replace('url ', ''); + return { + name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Spot', + description: ev.content.slice(0, 300), + city: getTag(ev, 'city'), + country: getTag(ev, 'country'), + location: getTag(ev, 'location'), + difficulty: getTag(ev, 'difficulty'), + features: getTags(ev, 't').filter((t) => t !== 'skatespot'), + geohash: getTag(ev, 'g'), + image: getTag(ev, 'image') ?? imetaUrl, + author_pubkey: ev.pubkey, + naddr: buildNaddr(ev), + url: `https://skate-spots.org/spot/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, + created_at: new Date(ev.created_at * 1000).toISOString(), + }; + }); + + return { count: result.length, spots: result }; }, - [] + [nostr] ); const findEvents = useCallback( @@ -244,10 +234,73 @@ export function useCVMClient() { to_date?: string; limit?: number; }): Promise<{ count: number; events: EventResult[] }> => { - const result = await callCVMTool('find_events', params as Record); - return parseToolResult(result); + const now = Math.floor(Date.now() / 1000); + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + + const raw = await relayGroup.query( + [{ kinds: [31923], limit: 200, since: now }], + { signal } + ); + + let events = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); + + if (params.city) { + const q = params.city.toLowerCase(); + events = events.filter( + (e) => + (getTag(e, 'location') ?? '').toLowerCase().includes(q) || + (getTag(e, 'city') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + if (params.event_type) { + const q = params.event_type.toLowerCase(); + events = events.filter( + (e) => + getTags(e, 't').some((t) => t.toLowerCase().includes(q)) || + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + if (params.to_date) { + const toTs = Math.floor(new Date(params.to_date).getTime() / 1000); + events = events.filter((e) => { + const start = parseInt(getTag(e, 'start') ?? '0'); + return !start || start <= toTs; + }); + } + + events.sort((a, b) => { + const startA = parseInt(getTag(a, 'start') ?? '0'); + const startB = parseInt(getTag(b, 'start') ?? '0'); + return startA - startB; + }); + events = events.slice(0, params.limit ?? 10); + + const result: EventResult[] = events.map((e) => { + const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; + const startTag = getTag(ev, 'start'); + const endTag = getTag(ev, 'end'); + return { + title: getTag(ev, 'title') ?? 'Untitled Event', + description: ev.content.slice(0, 300), + location: getTag(ev, 'location'), + start: startTag ? new Date(parseInt(startTag) * 1000).toISOString() : undefined, + end: endTag ? new Date(parseInt(endTag) * 1000).toISOString() : undefined, + type: getTags(ev, 't'), + image: getTag(ev, 'image'), + author_pubkey: ev.pubkey, + naddr: buildNaddr(ev), + url: `https://skate-spots.org/event/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, + }; + }); + + return { count: result.length, events: result }; }, - [] + [nostr] ); const findCoaches = useCallback( @@ -257,10 +310,64 @@ export function useCVMClient() { location?: string; limit?: number; }): Promise<{ count: number; profiles: CoachResult[] }> => { - const result = await callCVMTool('find_coaches', params as Record); - return parseToolResult(result); + const kindMap: Record = { + coach: [35001], + filmer: [35002], + editor: [35003], + any: [35001, 35002, 35003], + }; + const kinds = kindMap[params.role ?? 'any']; + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + + const raw = await relayGroup.query([{ kinds, limit: 200 }], { signal }); + let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); + + if (params.skills && params.skills.length > 0) { + profiles = profiles.filter((e) => { + const profileSkills = getTags(e, 'skill').map((s) => s.toLowerCase()); + const bio = (e as unknown as { content: string }).content.toLowerCase(); + return params.skills!.some( + (s) => + profileSkills.some((ps) => ps.includes(s.toLowerCase())) || + bio.includes(s.toLowerCase()) + ); + }); + } + + if (params.location) { + const q = params.location.toLowerCase(); + profiles = profiles.filter( + (e) => + (getTag(e, 'location') ?? '').toLowerCase().includes(q) || + (getTag(e, 'city') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + profiles = profiles.slice(0, params.limit ?? 10); + + const roleLabel = (kind: number): 'coach' | 'filmer' | 'editor' => + kind === 35001 ? 'coach' : kind === 35002 ? 'filmer' : 'editor'; + + const result: CoachResult[] = profiles.map((e) => { + const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; + return { + role: roleLabel(ev.kind), + pubkey: ev.pubkey, + bio: ev.content.slice(0, 300), + location: getTag(ev, 'location'), + skills: getTags(ev, 'skill'), + experience: getTag(ev, 'experience'), + lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'), + naddr: buildNaddr(ev), + url: `https://skate-spots.org/coach/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, + }; + }); + + return { count: result.length, profiles: result }; }, - [] + [nostr] ); const findGear = useCallback( @@ -271,16 +378,95 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; listings: GearResult[] }> => { - const result = await callCVMTool('find_gear', params as Record); - return parseToolResult(result); + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + + const raw = await relayGroup.query( + [{ kinds: [30402], limit: 200, '#t': ['skateboarding'] }], + { signal } + ); + + let listings = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); + + if (params.category) { + const q = params.category.toLowerCase(); + listings = listings.filter( + (e) => + getTags(e, 't').some((t) => t.toLowerCase().includes(q)) || + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + if (params.condition && params.condition !== 'any') { + listings = listings.filter((e) => + (getTag(e, 'condition') ?? '') + .toLowerCase() + .replace(/\s+/g, '_') + .includes(params.condition!) + ); + } + + if (params.search) { + const q = params.search.toLowerCase(); + listings = listings.filter( + (e) => + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) + ); + } + + listings.sort((a, b) => b.created_at - a.created_at); + listings = listings.slice(0, params.limit ?? 10); + + const result: GearResult[] = listings.map((e) => { + const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; + return { + title: getTag(ev, 'title') ?? 'Untitled Listing', + description: ev.content.slice(0, 300), + price: getTag(ev, 'price'), + currency: getTag(ev, 'currency'), + condition: getTag(ev, 'condition'), + categories: getTags(ev, 't'), + image: getTag(ev, 'image') ?? getTag(ev, 'thumb'), + seller_pubkey: ev.pubkey, + naddr: buildNaddr(ev), + created_at: new Date(ev.created_at * 1000).toISOString(), + }; + }); + + return { count: result.length, listings: result }; }, - [] + [nostr] ); const getCommunityStats = useCallback(async (): Promise => { - const result = await callCVMTool('get_community_stats', {}); - return parseToolResult(result); - }, []); + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + const now = Math.floor(Date.now() / 1000); + + const [spots, events, coaches, filmers, editors, gear] = await Promise.all([ + relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }), + relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }), + relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }), + relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }), + relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }), + relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }), + ]); + + return { + spots: deduplicateAddressable(spots as typeof spots & { tags: string[][] }[]).filter( + (e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot') + ).length, + upcoming_events: deduplicateAddressable(events as typeof events & { tags: string[][] }[]).length, + coaches: deduplicateAddressable(coaches as typeof coaches & { tags: string[][] }[]).length, + filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length, + editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length, + gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length, + relays_queried: DATA_RELAYS, + queried_at: new Date().toISOString(), + }; + }, [nostr]); return { findSpots,