event.ts raw
1 /**
2 * Infrastructure utilities for Nostr event parsing and manipulation.
3 *
4 * These are infrastructure-level helpers for working with raw Nostr events.
5 * They handle event parsing, tag extraction, and event comparison.
6 *
7 * Note: For domain-level event handling, consider using domain entities:
8 * import { Note, EventId } from '@/domain'
9 *
10 * The Note entity provides domain-focused methods like:
11 * - note.isReply, note.isRoot
12 * - note.mentions, note.references
13 * - note.hashtags, note.contentWarning
14 */
15
16 import { EMBEDDED_MENTION_REGEX, ExtendedKind } from '@/constants'
17 import client from '@/services/client.service'
18 import { TImetaInfo } from '@/types'
19 import { LRUCache } from 'lru-cache'
20 import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
21 import { fastEventHash, getPow } from 'nostr-tools/nip13'
22 import {
23 generateBech32IdFromATag,
24 generateBech32IdFromETag,
25 getImetaInfoFromImetaTag,
26 tagNameEquals
27 } from './tag'
28
29 const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 })
30 const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
31 const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })
32
33 export function isNsfwEvent(event: Event) {
34 return event.tags.some(
35 ([tagName, tagValue]) =>
36 tagName === 'content-warning' || (tagName === 't' && tagValue.toLowerCase() === 'nsfw')
37 )
38 }
39
40 export function isReplyNoteEvent(event: Event) {
41 if ([ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)) {
42 return true
43 }
44 if (event.kind !== kinds.ShortTextNote) return false
45
46 const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
47 if (cache !== undefined) return cache
48
49 const isReply = !!getParentTag(event)
50 EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply)
51 return isReply
52 }
53
54 export function isReplaceableEvent(kind: number) {
55 if (isNaN(kind)) return false
56 return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind)
57 }
58
59 export function isPictureEvent(event: Event) {
60 return event.kind === ExtendedKind.PICTURE
61 }
62
63 export function isProtectedEvent(event: Event) {
64 return event.tags.some(([tagName]) => tagName === '-')
65 }
66
67 export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) {
68 for (const [tagName, pubkey] of event.tags) {
69 if (tagName === 'p' && mutePubkeySet.has(pubkey)) {
70 return true
71 }
72 }
73 return false
74 }
75
76 export function getParentETag(event?: Event) {
77 if (!event) return undefined
78
79 if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
80 return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
81 }
82
83 if (event.kind !== kinds.ShortTextNote) return undefined
84
85 let tag = event.tags.find(([tagName, , , marker]) => {
86 return tagName === 'e' && marker === 'reply'
87 })
88 if (!tag) {
89 const embeddedEventIds = getEmbeddedNoteBech32Ids(event)
90 tag = event.tags.findLast(
91 ([tagName, tagValue, , marker]) =>
92 tagName === 'e' &&
93 !!tagValue &&
94 marker !== 'mention' &&
95 !embeddedEventIds.includes(tagValue)
96 )
97 }
98 return tag
99 }
100
101 function getLegacyParentATag(event?: Event) {
102 if (!event || event.kind !== kinds.ShortTextNote) {
103 return undefined
104 }
105
106 return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'reply')
107 }
108
109 export function getParentATag(event?: Event) {
110 if (
111 !event ||
112 ![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
113 ) {
114 return undefined
115 }
116
117 return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
118 }
119
120 export function getParentITag(event?: Event) {
121 if (
122 !event ||
123 ![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
124 ) {
125 return undefined
126 }
127
128 return event.tags.find(tagNameEquals('i')) ?? event.tags.find(tagNameEquals('I'))
129 }
130
131 export function getParentEventHexId(event?: Event) {
132 const tag = getParentETag(event)
133 return tag?.[1]
134 }
135
136 export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
137 if (!event) return undefined
138
139 if (event.kind === kinds.ShortTextNote) {
140 const tag = getLegacyParentATag(event) ?? getParentETag(event) ?? getLegacyRootATag(event)
141 if (!tag) return undefined
142 return { type: tag[0] === 'e' ? 'e' : 'a', tag }
143 }
144
145 // NIP-22
146 const parentKindStr = event.tags.find(tagNameEquals('k'))?.[1]
147 if (parentKindStr && isReplaceableEvent(parseInt(parentKindStr))) {
148 const tag = getParentATag(event)
149 return tag ? { type: 'a', tag } : undefined
150 }
151
152 const parentETag = getParentETag(event)
153 if (parentETag) {
154 return { type: 'e', tag: parentETag }
155 }
156
157 const parentITag = getParentITag(event)
158 return parentITag ? { type: 'i', tag: parentITag } : undefined
159 }
160
161 export function getParentBech32Id(event?: Event) {
162 const parentTag = getParentTag(event)
163 if (!parentTag) return undefined
164
165 return parentTag.type === 'e'
166 ? generateBech32IdFromETag(parentTag.tag)
167 : generateBech32IdFromATag(parentTag.tag)
168 }
169
170 export function getRootETag(event?: Event) {
171 if (!event) return undefined
172
173 if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
174 return event.tags.find(tagNameEquals('E'))
175 }
176
177 if (event.kind !== kinds.ShortTextNote) return undefined
178
179 let tag = event.tags.find(([tagName, , , marker]) => {
180 return tagName === 'e' && marker === 'root'
181 })
182 if (!tag) {
183 const embeddedEventIds = getEmbeddedNoteBech32Ids(event)
184 tag = event.tags.find(
185 ([tagName, tagValue]) => tagName === 'e' && !!tagValue && !embeddedEventIds.includes(tagValue)
186 )
187 }
188 return tag
189 }
190
191 function getLegacyRootATag(event?: Event) {
192 if (!event || event.kind !== kinds.ShortTextNote) {
193 return undefined
194 }
195
196 return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'root')
197 }
198
199 export function getRootATag(event?: Event) {
200 if (
201 !event ||
202 ![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
203 ) {
204 return undefined
205 }
206
207 return event.tags.find(tagNameEquals('A'))
208 }
209
210 export function getRootITag(event?: Event) {
211 if (
212 !event ||
213 ![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
214 ) {
215 return undefined
216 }
217
218 return event.tags.find(tagNameEquals('I'))
219 }
220
221 export function getRootEventHexId(event?: Event) {
222 const tag = getRootETag(event)
223 return tag?.[1]
224 }
225
226 export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
227 if (!event) return undefined
228
229 if (event.kind === kinds.ShortTextNote) {
230 const tag = getLegacyRootATag(event) ?? getRootETag(event)
231 if (!tag) return undefined
232 return { type: tag[0] === 'e' ? 'e' : 'a', tag }
233 }
234
235 // NIP-22
236 const rootKindStr = event.tags.find(tagNameEquals('K'))?.[1]
237 if (rootKindStr && isReplaceableEvent(parseInt(rootKindStr))) {
238 const tag = getRootATag(event)
239 return tag ? { type: 'a', tag } : undefined
240 }
241
242 const rootETag = getRootETag(event)
243 if (rootETag) {
244 return { type: 'e', tag: rootETag }
245 }
246
247 const rootITag = getRootITag(event)
248 return rootITag ? { type: 'i', tag: rootITag } : undefined
249 }
250
251 export function getRootBech32Id(event?: Event) {
252 const rootTag = getRootTag(event)
253 if (!rootTag) return undefined
254
255 return rootTag.type === 'e'
256 ? generateBech32IdFromETag(rootTag.tag)
257 : generateBech32IdFromATag(rootTag.tag)
258 }
259
260 export function getParentStuff(event: Event) {
261 const parentEventId = getParentBech32Id(event)
262 if (parentEventId) return { parentEventId }
263
264 const parentITag = getParentITag(event)
265 return { parentExternalContent: parentITag?.[1] }
266 }
267
268 // For internal identification of events
269 export function getEventKey(event: Event) {
270 return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
271 }
272
273 // Only used for e, E, a, A, i, I tags
274 export function getKeyFromTag([, tagValue]: (string | undefined)[]) {
275 return tagValue
276 }
277
278 export function getReplaceableCoordinate(kind: number, pubkey: string, d: string = '') {
279 return `${kind}:${pubkey}:${d}`
280 }
281
282 export function getReplaceableCoordinateFromEvent(event: Event) {
283 const d = event.tags.find(tagNameEquals('d'))?.[1]
284 return getReplaceableCoordinate(event.kind, event.pubkey, d)
285 }
286
287 export function getNoteBech32Id(event: Event) {
288 const hints = client.getEventHints(event.id).slice(0, 2)
289 if (isReplaceableEvent(event.kind)) {
290 const identifier = event.tags.find(tagNameEquals('d'))?.[1] ?? ''
291 return nip19.naddrEncode({ pubkey: event.pubkey, kind: event.kind, identifier, relays: hints })
292 }
293 return nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind, relays: hints })
294 }
295
296 export function getUsingClient(event: Event) {
297 return event.tags.find(tagNameEquals('client'))?.[1]
298 }
299
300 export function getImetaInfosFromEvent(event: Event) {
301 const imeta: TImetaInfo[] = []
302 event.tags.forEach((tag) => {
303 const imageInfo = getImetaInfoFromImetaTag(tag, event.pubkey)
304 if (imageInfo) {
305 imeta.push(imageInfo)
306 }
307 })
308 return imeta
309 }
310
311 export function getEmbeddedNoteBech32Ids(event: Event) {
312 const cache = EVENT_EMBEDDED_NOTES_CACHE.get(event.id)
313 if (cache) return cache
314
315 const embeddedNoteBech32Ids: string[] = []
316 const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
317 ;(event.content.match(embeddedNoteRegex) || []).forEach((note) => {
318 try {
319 const { type, data } = nip19.decode(note.split(':')[1])
320 if (type === 'nevent') {
321 embeddedNoteBech32Ids.push(data.id)
322 } else if (type === 'note') {
323 embeddedNoteBech32Ids.push(data)
324 }
325 } catch {
326 // ignore
327 }
328 })
329 EVENT_EMBEDDED_NOTES_CACHE.set(event.id, embeddedNoteBech32Ids)
330 return embeddedNoteBech32Ids
331 }
332
333 export function getEmbeddedPubkeys(event: Event) {
334 const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
335 if (cache) return cache
336
337 const embeddedPubkeySet = new Set<string>()
338 ;(event.content.match(EMBEDDED_MENTION_REGEX) || []).forEach((mention) => {
339 try {
340 const { type, data } = nip19.decode(mention.split(':')[1])
341 if (type === 'npub') {
342 embeddedPubkeySet.add(data)
343 } else if (type === 'nprofile') {
344 embeddedPubkeySet.add(data.pubkey)
345 }
346 } catch {
347 // ignore
348 }
349 })
350 const embeddedPubkeys = Array.from(embeddedPubkeySet)
351 EVENT_EMBEDDED_PUBKEYS_CACHE.set(event.id, embeddedPubkeys)
352 return embeddedPubkeys
353 }
354
355 export function getLatestEvent(events: Event[]): Event | undefined {
356 return events.sort((a, b) => b.created_at - a.created_at)[0]
357 }
358
359 export function getReplaceableEventIdentifier(event: Event) {
360 return event.tags.find(tagNameEquals('d'))?.[1] ?? ''
361 }
362
363 export function createFakeEvent(event: Partial<Event>): Event {
364 return {
365 id: '',
366 kind: 1,
367 pubkey: '',
368 content: '',
369 created_at: 0,
370 tags: [],
371 sig: '',
372 ...event
373 }
374 }
375
376 export async function minePow(
377 unsigned: UnsignedEvent,
378 difficulty: number
379 ): Promise<Omit<Event, 'sig'>> {
380 let count = 0
381
382 const event = unsigned as Omit<Event, 'sig'>
383 const tag = ['nonce', count.toString(), difficulty.toString()]
384
385 event.tags.push(tag)
386
387 return new Promise((resolve) => {
388 const mine = () => {
389 let iterations = 0
390
391 while (iterations < 1000) {
392 const now = Math.floor(new Date().getTime() / 1000)
393
394 if (now !== event.created_at) {
395 count = 0
396 event.created_at = now
397 }
398
399 tag[1] = (++count).toString()
400 event.id = fastEventHash(event)
401
402 if (getPow(event.id) >= difficulty) {
403 resolve(event)
404 return
405 }
406
407 iterations++
408 }
409
410 setTimeout(mine, 0)
411 }
412
413 mine()
414 })
415 }
416
417 // Legacy compare function for sorting compatibility
418 // If return 0, it means the two events are equal.
419 // If return a negative number, it means `b` should be retained, and `a` should be discarded.
420 // If return a positive number, it means `a` should be retained, and `b` should be discarded.
421 export function compareEvents(a: Event, b: Event): number {
422 if (a.created_at !== b.created_at) {
423 return a.created_at - b.created_at
424 }
425 // In case of replaceable events with the same timestamp, the event with the lowest id (first in lexical order) should be retained, and the other discarded.
426 if (a.id !== b.id) {
427 return a.id < b.id ? 1 : -1
428 }
429 return 0
430 }
431
432 // Returns the event that should be retained when comparing two events
433 export function getRetainedEvent(a: Event, b: Event): Event {
434 if (compareEvents(a, b) > 0) {
435 return a
436 }
437 return b
438 }
439
440 // Descending sort
441 export function sortEventsDesc(events: Event[]): Event[] {
442 return events.sort((a, b) => compareEvents(b, a))
443 }
444