controllers/EventController.js

/**
 * Controller for {@link Event|Events}
 *
 * @class
 */
class EventController {
  /**
   * Initializes a new EventController
   *
   * @param {Object} config configuration object
   * @param {Esdi} config.server Esdi server instance
   * @memberof EventController
   */
  init ({ server }) {
    this.server = server
    this.intervalEvents = new Map()
  }

  /**
   * Starts the EventController
   *
   * @listens Esdi#start
   * @memberof EventController
   */
  async start () {
    console.log('[#] Starting EventController...')

    // get all loaded interval Events and their database documents
    for (const e of this.server.events.values()) {
      // only consider interval Events
      if (e.type !== 'interval') continue

      // fetch this Event's database document
      let doc = await this.server.controllers.get('DatabaseController').fetchDoc({ db: 'event', id: e.name })

      // if missing, initialize this Event's database document
      doc = await this.initializeIfMissing(this.server, e, doc)

      // fetch the array of contexts with this Event enabled
      const allContexts = doc.contextTimestampPairs.map(p => p[0])

      // keep only enabled contexts
      const enabledContexts = []
      for (const c of allContexts) {
        const contextDoc = await this.server.controllers.get('DatabaseController').fetchDoc({
          db: 'event',
          id: `${e.name}_${c}`
        })
        if (contextDoc.enabled) enabledContexts.push(c)
      }

      // remember the interval Event and its array of enabled contexts
      this.intervalEvents.set(e.name, enabledContexts)
    }
  }

  /**
   * Stops the EventController
   *
   * @listens Esdi#stop
   * @memberof EventController
   */
  stop () {
    console.log('[#] Stopping EventController...')
    this.intervalEvents.clear()
  }

  /**
   * Called every {@link Esdi} server loop
   *
   * @listens Esdi#loop
   * @memberof EventController
   */
  loop () {
    // iterate over the loaded interval Events and fire the handler for each enabled context
    this.intervalEvents.forEach(async (contexts, eventName) => {
      const event = Array.from(this.server.events.values()).filter(e => e.name === eventName)[0]

      // fetch this Event's database document
      let doc = await this.server.controllers.get('DatabaseController').fetchDoc({ db: 'event', id: event.name })

      // if missing, initialize this Event's database document
      doc = await this.initializeIfMissing(this.server, event, doc)

      // iterate over each context
      for (const context of contexts) {
        const timestamp = doc.contextTimestampPairs.find(pair => pair[0] === context)[1]

        // fetch database document for each context to check if enabled
        const { enabled, config } = await this.server.controllers.get('DatabaseController').fetchDoc({
          db: 'event',
          id: `${event.name}_${context}`
        })
        // apply custom config for this context
        const interval = (config && config.interval ? config.interval : event.interval)

        // determine the Event's timing interval
        const { day, hr, min, sec } = this.server.utils.parseIntervalString(interval)

        const intervalAmount = (sec * 1000) + (min * 60000) + (hr * 3600000) + (day * 86400000)

        const now = Date.now()
        const nextInterval = timestamp + intervalAmount

        // if it's time for this Event to fire for this context and it is enabled
        if (now > nextInterval && enabled) {
          // determine the capitalized name of this context
          const contextName = (event.context === 'guild' ? 'Server' : 'Channel')

          // check if bot can see context
          let con
          try {
            // determine this Event's context
            const fetchedContext = await this.fetchEventContext({
              context: {
                id: context
              },
              event
            })

            con = {
              type: event.context,
              ctx: (event.context === 'global' ? 'global' : fetchedContext)
            }
          } catch (e) {
            // update locally to avoid handling every loop
            const local = this.server.controllers.get('EventController').intervalEvents.get(event.name)
            this.server.controllers.get('EventController').intervalEvents.set(event.name, [...local.filter(c => c !== context)])

            // save the Event as disabled in the context's database document
            await this.server.controllers.get('DatabaseController').updateDoc({
              db: 'event',
              id: `${event.name}_${context}`,
              payload: {
                enabled: false
              }
            })

            return console.log(`Bot doesn't know about ${contextName}<${context}> for ${event.name} Event:`, e)
          }

          // update this context's timestamp in the database document for this Event
          const payload = doc.contextTimestampPairs.filter(p => p[0] !== context)
          await this.server.controllers.get('DatabaseController').updateDoc({
            db: 'event',
            id: event.name,
            payload: {
              contextTimestampPairs: [...payload, [context, now]]
            }
          })

          // update the timestamp in this context's database document for this Event
          await this.server.controllers.get('DatabaseController').updateDoc({
            db: 'event',
            id: `${event.name}_${context}`,
            payload: {
              lastHandled: now
            }
          })

          // fire the handler for this Event for this context
          event.handler({ server: this.server, context: con.ctx })

          // announce handling of Event
          console.log(`${event.name} Event was handled ${con.type === 'global' ? 'globally' : `for ${this.server.utils.capitalize(con.type)}<${con.ctx.id}>`} @ ${new Date().toLocaleString()} PT`)
        }
      }
    })
  }

  /**
   * Registers Discord Events to the provided discord.js Client
   *
   * @param {external:Client} client discord.js Client
   * @memberof EventController
   */
  registerDiscordEvents (client) {
    this.server.events.forEach(event => {
      if (!event.discordEventName || !event.handler || event.type !== 'discord') return
      client.on(event.discordEventName, event.handler.bind(client.discordController.server))
    })
  }

  /**
   * Fetches the discord.js object representing the provided context
   *
   * @param {Object} config configuration object
   * @param {Object} config.context context configuration object
   * @param {Event} config.event Event for context
   * @returns {Object|String} context object or 'global' for global context
   * @memberof EventController
   */
  async fetchEventContext ({ context, event }) {
    if (event.context !== 'global') {
      if (event.context === 'guild') {
        const result = await this.server.controllers.get('DiscordController').client.guilds.fetch(context.id)
        return result
      } else if (event.context === 'channel') {
        const result = await this.server.controllers.get('DiscordController').client.channels.fetch(context.id)
        return result
      }
    } else {
      return 'global'
    }
  }

  /**
   * Updates the interval Event contexts stored on the local Esdi server instance
   *
   * @param {Object} config configuration object
   * @param {Event} config.event Event for which to update available contexts
   * @param {Function} config.filterFunc function that filters the context array
   * @param {String} config.newContext ID of new context to add to array
   * @memberof EventController
   */
  updateLocalEventContexts ({ event, filterFunc, newContext }) {
    const local = this.server.controllers.get('EventController').intervalEvents.get(event.name)

    if (newContext) {
      this.server.controllers.get('EventController').intervalEvents.set(event.name, [...local.filter(c => filterFunc(c)), newContext])
    } else {
      this.server.controllers.get('EventController').intervalEvents.set(event.name, local.filter(c => filterFunc(c)))
    }
  }

  /**
   * Helper method that initializes the database document if missing
   *
   * @param {Esdi} server Esdi server instance
   * @param {Event} event Event for which to initialize database document
   * @param {Object} doc database document to check before starting
   * @memberof EventController
   */
  async initializeIfMissing (server, event, doc) {
    if (doc.status === 404 || !doc.contextTimestampPairs) {
      return await server.controllers.get('DatabaseController').updateDoc({
        db: 'event',
        id: event.name,
        payload: {
          contextTimestampPairs: []
        }
      })
    } else {
      return doc
    }
  }
}

// factory
module.exports = new EventController()