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 { TImetaInfo } from '@/types'
18 import { LRUCache } from 'lru-cache'
19 import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
20 import { getPow } from 'nostr-tools/nip13'
21 import { getEventHash } from 'nostr-tools'
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
288 export function getUsingClient(event: Event) {
289 return event.tags.find(tagNameEquals('client'))?.[1]
290 }
291
292 export function getImetaInfosFromEvent(event: Event) {
293 const imeta: TImetaInfo[] = []
294 event.tags.forEach((tag) => {
295 const imageInfo = getImetaInfoFromImetaTag(tag, event.pubkey)
296 if (imageInfo) {
297 imeta.push(imageInfo)
298 }
299 })
300 return imeta
301 }
302
303 export function getEmbeddedNoteBech32Ids(event: Event) {
304 const cache = EVENT_EMBEDDED_NOTES_CACHE.get(event.id)
305 if (cache) return cache
306
307 const embeddedNoteBech32Ids: string[] = []
308 const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
309 ;(event.content.match(embeddedNoteRegex) || []).forEach((note) => {
310 try {
311 const { type, data } = nip19.decode(note.split(':')[1])
312 if (type === 'nevent') {
313 embeddedNoteBech32Ids.push(data.id)
314 } else if (type === 'note') {
315 embeddedNoteBech32Ids.push(data)
316 }
317 } catch {
318 // ignore
319 }
320 })
321 EVENT_EMBEDDED_NOTES_CACHE.set(event.id, embeddedNoteBech32Ids)
322 return embeddedNoteBech32Ids
323 }
324
325 export function getEmbeddedPubkeys(event: Event) {
326 const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
327 if (cache) return cache
328
329 const embeddedPubkeySet = new Set<string>()
330 ;(event.content.match(EMBEDDED_MENTION_REGEX) || []).forEach((mention) => {
331 try {
332 const { type, data } = nip19.decode(mention.split(':')[1])
333 if (type === 'npub') {
334 embeddedPubkeySet.add(data)
335 } else if (type === 'nprofile') {
336 embeddedPubkeySet.add(data.pubkey)
337 }
338 } catch {
339 // ignore
340 }
341 })
342 const embeddedPubkeys = Array.from(embeddedPubkeySet)
343 EVENT_EMBEDDED_PUBKEYS_CACHE.set(event.id, embeddedPubkeys)
344 return embeddedPubkeys
345 }
346
347 export function getLatestEvent(events: Event[]): Event | undefined {
348 return events.sort((a, b) => b.created_at - a.created_at)[0]
349 }
350
351 export function getReplaceableEventIdentifier(event: Event) {
352 return event.tags.find(tagNameEquals('d'))?.[1] ?? ''
353 }
354
355 export function createFakeEvent(event: Partial<Event>): Event {
356 return {
357 id: '',
358 kind: 1,
359 pubkey: '',
360 content: '',
361 created_at: 0,
362 tags: [],
363 sig: '',
364 ...event
365 }
366 }
367
368 export async function minePow(
369 unsigned: UnsignedEvent,
370 difficulty: number
371 ): Promise<Omit<Event, 'sig'>> {
372 let count = 0
373
374 const event = unsigned as Omit<Event, 'sig'>
375 const tag = ['nonce', count.toString(), difficulty.toString()]
376
377 event.tags.push(tag)
378
379 return new Promise((resolve) => {
380 const mine = () => {
381 let iterations = 0
382
383 while (iterations < 1000) {
384 const now = Math.floor(new Date().getTime() / 1000)
385
386 if (now !== event.created_at) {
387 count = 0
388 event.created_at = now
389 }
390
391 tag[1] = (++count).toString()
392 event.id = getEventHash(event)
393
394 if (getPow(event.id) >= difficulty) {
395 resolve(event)
396 return
397 }
398
399 iterations++
400 }
401
402 setTimeout(mine, 0)
403 }
404
405 mine()
406 })
407 }
408
409 // Legacy compare function for sorting compatibility
410 // If return 0, it means the two events are equal.
411 // If return a negative number, it means `b` should be retained, and `a` should be discarded.
412 // If return a positive number, it means `a` should be retained, and `b` should be discarded.
413 export function compareEvents(a: Event, b: Event): number {
414 if (a.created_at !== b.created_at) {
415 return a.created_at - b.created_at
416 }
417 // 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.
418 if (a.id !== b.id) {
419 return a.id < b.id ? 1 : -1
420 }
421 return 0
422 }
423
424 // Returns the event that should be retained when comparing two events
425 export function getRetainedEvent(a: Event, b: Event): Event {
426 if (compareEvents(a, b) > 0) {
427 return a
428 }
429 return b
430 }
431
432 // Descending sort
433 export function sortEventsDesc(events: Event[]): Event[] {
434 return events.sort((a, b) => compareEvents(b, a))
435 }
436