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