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