session.js

const { showWarning } = require('./core/helpers/utils')
const debug = require('debug')('opengram:session')

const storeSym = Symbol('store')
const ttlSym = Symbol('ttl')
const propSym = Symbol('property')
const keyGeneratorFnSym = Symbol('keyGeneratorFn')
const storeSetMethodSym = Symbol('storeSetMethod')

const WARN_AFTER_SAVE_TEXT = 'A write/read attempt on the session after it was saved detected! Perhaps the chain of promises has broken.'
const ERROR_SESSION_KEY_NOT_DEFINED = 'Cannot access session data because this update does not belong to a chat, so the session key not available!'

/**
 * @module Session
 */

/**
 * @typedef {object} SessionOptions
 * @property {Function} [getSessionKey] Function for generating session key.
 * @property {string} [property] Sets session property name in context
 * @property {number} [ttl] Time to live
 * @property {object} [store] Store
 */

class Session {
  /**
   * Constructor of session class
   *
   * @param {SessionOptions} [options] Options
   */
  constructor (options = {}) {
    this[storeSym] = options.store ?? new Map()
    this[propSym] = options.property ?? 'session'
    this[keyGeneratorFnSym] = options.getSessionKey ?? getSessionKey
    this[ttlSym] = options.ttl && options.ttl * 1000
    this[storeSetMethodSym] = typeof this[storeSym].put === 'function' ? 'put' : 'set'
  }

  /**
   * Store getter
   *
   * Return store object given in constructor
   *
   * @return {object}
   */
  get store () {
    return this[storeSym]
  }

  /**
   * TTL getter
   *
   * Returns current ttl in **seconds** or `undefined` value
   *
   * @return {number|undefined}
   */
  get ttl () {
    return this[ttlSym]
  }

  /**
   * TTL setter
   *
   * Sets new ttl for session
   *
   * @return {void}
   */
  set ttl (seconds) {
    this[ttlSym] = seconds
  }

  /**
   * Returns session middleware
   *
   * @return {Middleware}
   */
  middleware () {
    const method = this[storeSetMethodSym]
    const propName = this[propSym]
    const getSessionKey = this[keyGeneratorFnSym]

    return async (ctx, next) => {
      const key = getSessionKey(ctx)

      if (!key) {
        Object.defineProperty(ctx, propName, {
          get: () => {
            throw new Error(ERROR_SESSION_KEY_NOT_DEFINED)
          },
          set: () => {
            throw new Error(ERROR_SESSION_KEY_NOT_DEFINED)
          }
        })
        return await next()
      }

      let afterSave = false

      const wrapSession = (targetSessionObject) => (
        new Proxy({ ...targetSessionObject }, {
          set: (target, prop, value) => {
            if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`)
            target[prop] = value
            return true
          },
          get (target, prop) {
            if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`)
            return target[prop]
          },
          deleteProperty: (target, prop) => {
            if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`)
            delete target[prop]
            return true
          }
        })
      )

      const now = Date.now()

      const state = await Promise.resolve(
        this.store.get(key)
      ) || { session: {} }

      let { session, expires } = state

      // Wrap session to Proxy
      session = wrapSession(session)

      debug('session snapshot', key, session)

      if (expires && expires < now) {
        debug('session expired', key)
        session = {}
      }

      Object.defineProperty(ctx, propName, {
        get: () => session,
        set: (newSession) => {
          // Wrap session to Proxy
          session = wrapSession(newSession)
        }
      })

      const result = await next(ctx)

      debug('save session', key, session)
      const newSession = { ...session } // Bypass proxy
      afterSave = true
      await Promise.resolve(
        this.store[method](key, {
          session: newSession,
          expires: this.ttl ? now + this.ttl : null
        })
      )
      debug('session saved', key, session)

      return result
    }
  }
}

/**
 * Creates session middleware with given store and options
 *
 * ### Custom session property
 * You can set custom session property using `property` option
 *
 * ```js
 * bot.use(
 *   session({
 *     property: 'propName'
 *   })
 * )
 * ```
 * For this example, session available in `ctx.propName`
 *
 * ### Custom session key
 * By default, session key in storage generated with this function:
 * ```js
 * (ctx) => {
 *  return ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}`
 * }
 * ```
 *
 * For example, you can redefine this function for storing session with chat key, like this:
 * ```js
 * (ctx) => {
 *  return ctx.chat && `${ctx.chat.id}`
 * }
 * ```
 *
 * If you don't want to add session object, you can return `null` or `undefined` from this function.
 *
 * For example, session working only in chat updates:
 * ```js
 * (ctx) => {
 *  if (ctx.chat.type === 'private') return null // When chat type is private `ctx.session` not available
 *  return ctx.chat && `${ctx.chat.id}`
 * }
 * ```
 *
 * ### TTL ( Time to live)
 * This parameter can set in `ttl` option in **seconds**, expire time of session,
 * by default session time not limited, but if you use in memory store like
 * [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or other,
 * session be destroyed after bot restart
 *
 * @param {SessionOptions} [options] Session options
 * @return {Session}
 */
function sessionFactory (options) {
  return new Session(options)
}

function getSessionKey (ctx) {
  return ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}`
}

module.exports = sessionFactory