nak-parser.ts raw

   1  import { TFeedSubRequest } from '@/types'
   2  import { Filter } from 'nostr-tools'
   3  import { decode } from 'nostr-tools/nip19'
   4  import { normalizeUrl } from './url'
   5  
   6  /**
   7   * Check if the input is a nak req command
   8   */
   9  function isNakReqCommand(input: string): boolean {
  10    return input.startsWith('nak req ') || input.startsWith('req ')
  11  }
  12  
  13  /**
  14   * Parse a nak req command and return filter and relays
  15   *
  16   * Supported options:
  17   * --author, -a: only accept events from these authors (pubkey as hex)
  18   * --id, -i: only accept events with these ids (hex)
  19   * --kind, -k: only accept events with these kind numbers
  20   * --search: a nip50 search query
  21   * --tag, -t: takes a tag like -t e=<id>
  22   * -d: shortcut for --tag d=<value>
  23   * -e: shortcut for --tag e=<value>
  24   * -p: shortcut for --tag p=<value>
  25   *
  26   * Remaining arguments are treated as relay URLs
  27   */
  28  export function parseNakReqCommand(input: string): TFeedSubRequest | null {
  29    const trimmed = input.trim()
  30    if (!isNakReqCommand(trimmed)) {
  31      return null
  32    }
  33  
  34    // Remove "nak req " or "req " prefix
  35    const argsString = trimmed.startsWith('nak') ? trimmed.slice(8).trim() : trimmed.slice(3).trim()
  36    if (!argsString) {
  37      return { filter: {}, urls: [] }
  38    }
  39  
  40    const args = parseArgs(argsString)
  41    const filter: Omit<Filter, 'since' | 'until'> = {}
  42    const relays: string[] = []
  43  
  44    let i = 0
  45    while (i < args.length) {
  46      const arg = args[i]
  47  
  48      // Handle options with values
  49      if (arg === '--author' || arg === '-a') {
  50        const value = args[++i]
  51        const hexId = value ? parseHexId(value) : null
  52        if (hexId) {
  53          if (!filter.authors) filter.authors = []
  54          if (!filter.authors.includes(hexId)) {
  55            filter.authors.push(hexId)
  56          }
  57        }
  58      } else if (arg === '--id' || arg === '-i') {
  59        const value = args[++i]
  60        const hexId = value ? parseHexId(value) : null
  61        if (hexId) {
  62          if (!filter.ids) filter.ids = []
  63          if (!filter.ids.includes(hexId)) {
  64            filter.ids.push(hexId)
  65          }
  66        }
  67      } else if (arg === '--kind' || arg === '-k') {
  68        const value = args[++i]
  69        if (value && /^\d+$/.test(value)) {
  70          const kind = parseInt(value, 10)
  71          if (!filter.kinds) filter.kinds = []
  72          if (!filter.kinds.includes(kind)) {
  73            filter.kinds.push(kind)
  74          }
  75        }
  76      } else if (arg === '--search') {
  77        const value = args[++i]
  78        if (value) {
  79          filter.search = value
  80        }
  81      } else if (arg === '--tag' || arg === '-t') {
  82        const value = args[++i]
  83        if (value) {
  84          const [tagName, tagValue] = parseTagValue(value)
  85          if (tagName && tagValue) {
  86            const tagKey = `#${tagName}`
  87            const filterRecord = filter as Record<string, string[]>
  88            if (!filterRecord[tagKey]) {
  89              filterRecord[tagKey] = []
  90            }
  91            if (!filterRecord[tagKey].includes(tagValue)) {
  92              filterRecord[tagKey].push(tagValue)
  93            }
  94          }
  95        }
  96      } else if (arg === '-d') {
  97        const value = args[++i]
  98        if (value) {
  99          if (!filter['#d']) filter['#d'] = []
 100          if (!filter['#d'].includes(value)) {
 101            filter['#d'].push(value)
 102          }
 103        }
 104      } else if (arg === '-e') {
 105        const value = args[++i]
 106        if (value && isValidHexId(value)) {
 107          if (!filter['#e']) filter['#e'] = []
 108          if (!filter['#e'].includes(value)) {
 109            filter['#e'].push(value)
 110          }
 111        }
 112      } else if (arg === '-p') {
 113        const value = args[++i]
 114        if (value && isValidHexId(value)) {
 115          if (!filter['#p']) filter['#p'] = []
 116          if (!filter['#p'].includes(value)) {
 117            filter['#p'].push(value)
 118          }
 119        }
 120      } else if (!arg.startsWith('-')) {
 121        // Treat as relay URL
 122        try {
 123          const url = normalizeUrl(arg)
 124          if (url.startsWith('wss://') || url.startsWith('ws://')) {
 125            if (!relays.includes(url)) {
 126              relays.push(url)
 127            }
 128          }
 129        } catch {
 130          // Ignore invalid URLs
 131        }
 132      }
 133  
 134      i++
 135    }
 136  
 137    return { filter, urls: relays }
 138  }
 139  
 140  /**
 141   * Parse command line arguments, handling quoted strings
 142   */
 143  function parseArgs(input: string): string[] {
 144    const args: string[] = []
 145    let current = ''
 146    let inQuote: string | null = null
 147  
 148    for (let i = 0; i < input.length; i++) {
 149      const char = input[i]
 150  
 151      if (inQuote) {
 152        if (char === inQuote) {
 153          inQuote = null
 154        } else {
 155          current += char
 156        }
 157      } else if (char === '"' || char === "'") {
 158        inQuote = char
 159      } else if (char === ' ' || char === '\t') {
 160        if (current) {
 161          args.push(current)
 162          current = ''
 163        }
 164      } else {
 165        current += char
 166      }
 167    }
 168  
 169    if (current) {
 170      args.push(current)
 171    }
 172  
 173    return args
 174  }
 175  
 176  /**
 177   * Parse tag value in format "name=value"
 178   */
 179  function parseTagValue(value: string): [string, string] | [null, null] {
 180    const idx = value.indexOf('=')
 181    if (idx === -1) {
 182      return [null, null]
 183    }
 184    return [value.slice(0, idx), value.slice(idx + 1)]
 185  }
 186  
 187  /**
 188   * Check if a string is valid hex of specified length
 189   */
 190  function isValidHexId(value: string): boolean {
 191    return new RegExp(`^[0-9a-fA-F]{64}$`).test(value)
 192  }
 193  
 194  function parseHexId(value: string): string | null {
 195    if (isValidHexId(value)) {
 196      return value
 197    }
 198    if (['nevent', 'note', 'npub', 'nprofile'].every((prefix) => !value.startsWith(prefix))) {
 199      return null
 200    }
 201  
 202    try {
 203      const { type, data } = decode(value)
 204      if (type === 'nevent') {
 205        return data.id
 206      }
 207      if (type === 'note' || type === 'npub') {
 208        return data
 209      }
 210      if (type === 'nprofile') {
 211        return data.pubkey
 212      }
 213      return null
 214    } catch {
 215      return null
 216    }
 217  }
 218  
 219  /**
 220   * Format a filter for display
 221   */
 222  export function formatFeedRequest(request: TFeedSubRequest): string {
 223    const parts: string[] = []
 224  
 225    if (request.filter.kinds?.length) {
 226      parts.push(`kinds: ${request.filter.kinds.join(', ')}`)
 227    }
 228    if (request.filter.authors?.length) {
 229      parts.push(`authors: ${request.filter.authors.length}`)
 230    }
 231    if (request.filter.ids?.length) {
 232      parts.push(`ids: ${request.filter.ids.length}`)
 233    }
 234    if (request.filter.search) {
 235      parts.push(`search: "${request.filter.search}"`)
 236    }
 237  
 238    // Check for tag filters
 239    for (const key of Object.keys(request.filter)) {
 240      if (key.startsWith('#')) {
 241        const values = request.filter[key as keyof typeof request.filter] as string[]
 242        if (values?.length) {
 243          parts.push(`${key}: ${values.length}`)
 244        }
 245      }
 246    }
 247  
 248    if (request.urls.length) {
 249      parts.push(`relays: ${request.urls.length}`)
 250    }
 251  
 252    return parts.join(' | ') || 'No filters'
 253  }
 254