graph-query.service.ts raw

   1  import { normalizeUrl } from '@/lib/url'
   2  import { TGraphQueryCapability, TRelayInfo } from '@/types'
   3  import { GraphQuery, GraphResponse } from '@/types/graph'
   4  import { Event as NEvent, Filter, SimplePool, verifyEvent } from 'nostr-tools'
   5  import relayInfoService from './relay-info.service'
   6  import storage from './local-storage.service'
   7  
   8  // Graph query response kinds (relay-signed)
   9  const GRAPH_RESPONSE_KINDS = {
  10    FOLLOWS: 39000,
  11    MENTIONS: 39001,
  12    THREAD: 39002
  13  }
  14  
  15  class GraphQueryService {
  16    static instance: GraphQueryService
  17  
  18    private pool: SimplePool
  19    private capabilityCache = new Map<string, TGraphQueryCapability | null>()
  20    private capabilityFetchPromises = new Map<string, Promise<TGraphQueryCapability | null>>()
  21  
  22    constructor() {
  23      this.pool = new SimplePool()
  24    }
  25  
  26    public static getInstance(): GraphQueryService {
  27      if (!GraphQueryService.instance) {
  28        GraphQueryService.instance = new GraphQueryService()
  29      }
  30      return GraphQueryService.instance
  31    }
  32  
  33    /**
  34     * Check if graph queries are enabled in settings
  35     */
  36    isEnabled(): boolean {
  37      return storage.getGraphQueriesEnabled()
  38    }
  39  
  40    /**
  41     * Get relay's graph query capability via NIP-11
  42     */
  43    async getRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
  44      const normalizedUrl = normalizeUrl(url)
  45  
  46      // Check memory cache first
  47      if (this.capabilityCache.has(normalizedUrl)) {
  48        return this.capabilityCache.get(normalizedUrl) ?? null
  49      }
  50  
  51      // Check if already fetching
  52      const existingPromise = this.capabilityFetchPromises.get(normalizedUrl)
  53      if (existingPromise) {
  54        return existingPromise
  55      }
  56  
  57      // Fetch capability
  58      const fetchPromise = this._fetchRelayCapability(normalizedUrl)
  59      this.capabilityFetchPromises.set(normalizedUrl, fetchPromise)
  60  
  61      try {
  62        const capability = await fetchPromise
  63        this.capabilityCache.set(normalizedUrl, capability)
  64        return capability
  65      } finally {
  66        this.capabilityFetchPromises.delete(normalizedUrl)
  67      }
  68    }
  69  
  70    private async _fetchRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
  71      try {
  72        const relayInfo = (await relayInfoService.getRelayInfo(url)) as TRelayInfo | undefined
  73  
  74        if (!relayInfo?.graph_query?.enabled) {
  75          return null
  76        }
  77  
  78        return relayInfo.graph_query
  79      } catch {
  80        return null
  81      }
  82    }
  83  
  84    /**
  85     * Check if a relay supports a specific graph query method
  86     */
  87    async supportsMethod(url: string, method: GraphQuery['method']): Promise<boolean> {
  88      const capability = await this.getRelayCapability(url)
  89      if (!capability?.enabled) return false
  90      return capability.methods.includes(method)
  91    }
  92  
  93    /**
  94     * Find a relay supporting graph queries from a list of URLs
  95     */
  96    async findGraphCapableRelay(
  97      urls: string[],
  98      method?: GraphQuery['method']
  99    ): Promise<string | null> {
 100      if (!this.isEnabled()) return null
 101  
 102      // Check capabilities in parallel
 103      const results = await Promise.all(
 104        urls.map(async (url) => {
 105          const capability = await this.getRelayCapability(url)
 106          if (!capability?.enabled) return null
 107          if (method && !capability.methods.includes(method)) return null
 108          return url
 109        })
 110      )
 111  
 112      return results.find((url) => url !== null) ?? null
 113    }
 114  
 115    /**
 116     * Execute a graph query against a specific relay
 117     */
 118    async executeQuery(relayUrl: string, query: GraphQuery): Promise<GraphResponse | null> {
 119      if (!this.isEnabled()) return null
 120  
 121      const capability = await this.getRelayCapability(relayUrl)
 122      if (!capability?.enabled) {
 123        console.warn(`Relay ${relayUrl} does not support graph queries`)
 124        return null
 125      }
 126  
 127      // Validate method support
 128      if (!capability.methods.includes(query.method)) {
 129        console.warn(`Relay ${relayUrl} does not support method: ${query.method}`)
 130        return null
 131      }
 132  
 133      // Validate depth
 134      const depth = query.depth ?? 1
 135      if (depth > capability.max_depth) {
 136        console.warn(`Requested depth ${depth} exceeds relay max ${capability.max_depth}`)
 137        query = { ...query, depth: capability.max_depth }
 138      }
 139  
 140      // Build the filter with graph extension
 141      // The _graph field is a custom extension not in the standard Filter type
 142      const filter = {
 143        _graph: query
 144      } as Filter
 145  
 146      // Determine expected response kind
 147      const expectedKind = this.getExpectedResponseKind(query.method)
 148  
 149      return new Promise<GraphResponse | null>(async (resolve) => {
 150        let resolved = false
 151        const timeout = setTimeout(() => {
 152          if (!resolved) {
 153            resolved = true
 154            resolve(null)
 155          }
 156        }, 30000) // 30s timeout for graph queries
 157  
 158        try {
 159          const relay = await this.pool.ensureRelay(relayUrl, { connectionTimeout: 5000 })
 160  
 161          const sub = relay.subscribe([filter], {
 162            onevent: (event: NEvent) => {
 163              // Verify it's a relay-signed graph response
 164              if (event.kind !== expectedKind) return
 165  
 166              // Verify event signature
 167              if (!verifyEvent(event)) {
 168                console.warn('Invalid signature on graph response')
 169                return
 170              }
 171  
 172              try {
 173                const response = JSON.parse(event.content) as GraphResponse
 174                if (!resolved) {
 175                  resolved = true
 176                  clearTimeout(timeout)
 177                  sub.close()
 178                  resolve(response)
 179                }
 180              } catch (e) {
 181                console.error('Failed to parse graph response:', e)
 182              }
 183            },
 184            oneose: () => {
 185              // If we got EOSE without a response, the query may not be supported
 186              if (!resolved) {
 187                resolved = true
 188                clearTimeout(timeout)
 189                sub.close()
 190                resolve(null)
 191              }
 192            }
 193          })
 194        } catch (error) {
 195          console.error('Failed to connect to relay for graph query:', error)
 196          if (!resolved) {
 197            resolved = true
 198            clearTimeout(timeout)
 199            resolve(null)
 200          }
 201        }
 202      })
 203    }
 204  
 205    private getExpectedResponseKind(method: GraphQuery['method']): number {
 206      switch (method) {
 207        case 'follows':
 208        case 'followers':
 209          return GRAPH_RESPONSE_KINDS.FOLLOWS
 210        case 'mentions':
 211          return GRAPH_RESPONSE_KINDS.MENTIONS
 212        case 'thread':
 213          return GRAPH_RESPONSE_KINDS.THREAD
 214        default:
 215          return GRAPH_RESPONSE_KINDS.FOLLOWS
 216      }
 217    }
 218  
 219    /**
 220     * High-level method: Query follow graph with fallback
 221     */
 222    async queryFollowGraph(
 223      relayUrls: string[],
 224      seed: string,
 225      depth: number = 1
 226    ): Promise<GraphResponse | null> {
 227      const graphRelay = await this.findGraphCapableRelay(relayUrls, 'follows')
 228      if (!graphRelay) return null
 229  
 230      return this.executeQuery(graphRelay, {
 231        method: 'follows',
 232        seed,
 233        depth
 234      })
 235    }
 236  
 237    /**
 238     * High-level method: Query follower graph
 239     */
 240    async queryFollowerGraph(
 241      relayUrls: string[],
 242      seed: string,
 243      depth: number = 1
 244    ): Promise<GraphResponse | null> {
 245      const graphRelay = await this.findGraphCapableRelay(relayUrls, 'followers')
 246      if (!graphRelay) return null
 247  
 248      return this.executeQuery(graphRelay, {
 249        method: 'followers',
 250        seed,
 251        depth
 252      })
 253    }
 254  
 255    /**
 256     * High-level method: Query thread with optional ref aggregation
 257     */
 258    async queryThread(
 259      relayUrls: string[],
 260      eventId: string,
 261      depth: number = 10,
 262      options?: {
 263        inboundRefKinds?: number[]
 264        outboundRefKinds?: number[]
 265      }
 266    ): Promise<GraphResponse | null> {
 267      const graphRelay = await this.findGraphCapableRelay(relayUrls, 'thread')
 268      if (!graphRelay) return null
 269  
 270      const query: GraphQuery = {
 271        method: 'thread',
 272        seed: eventId,
 273        depth
 274      }
 275  
 276      if (options?.inboundRefKinds?.length) {
 277        query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
 278      }
 279  
 280      if (options?.outboundRefKinds?.length) {
 281        query.outbound_refs = [{ kinds: options.outboundRefKinds, from_depth: 0 }]
 282      }
 283  
 284      return this.executeQuery(graphRelay, query)
 285    }
 286  
 287    /**
 288     * High-level method: Query mentions with aggregation
 289     */
 290    async queryMentions(
 291      relayUrls: string[],
 292      pubkey: string,
 293      options?: {
 294        inboundRefKinds?: number[] // e.g., [7, 9735] for reactions and zaps
 295      }
 296    ): Promise<GraphResponse | null> {
 297      const graphRelay = await this.findGraphCapableRelay(relayUrls, 'mentions')
 298      if (!graphRelay) return null
 299  
 300      const query: GraphQuery = {
 301        method: 'mentions',
 302        seed: pubkey
 303      }
 304  
 305      if (options?.inboundRefKinds?.length) {
 306        query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
 307      }
 308  
 309      return this.executeQuery(graphRelay, query)
 310    }
 311  
 312    /**
 313     * Clear capability cache for a relay (e.g., when relay info is updated)
 314     */
 315    clearCapabilityCache(url?: string): void {
 316      if (url) {
 317        const normalizedUrl = normalizeUrl(url)
 318        this.capabilityCache.delete(normalizedUrl)
 319      } else {
 320        this.capabilityCache.clear()
 321      }
 322    }
 323  }
 324  
 325  const instance = GraphQueryService.getInstance()
 326  export default instance
 327