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