session.js

const Debug = require('debug')
const lowdb = require('lowdb')
const storageFileAsync = require('lowdb/adapters/FileAsync')
const storageFileSync = require('lowdb/adapters/FileSync')
const storageMemory = require('lowdb/adapters/Memory')

const lodashId = require('./lodash-id.js')

const debug = Debug('telegraf:session-local')

/**
 * Represents a wrapper around locally stored session, it's {@link LocalSession#middleware|middleware} & lowdb
 *
 * @param {Object} [options] - Options for {@link LocalSession|LocalSession} & {@link https://github.com/typicode/lowdb|lowdb}
 * @param {String} [options.database] - Name or path to database file `default:` `'sessions.json'`
 * @param {String} [options.property] - Name of property in {@link https://telegraf.js.org/#/?id=context|Telegraf Context} where session object will be located `default:` `'session'`
 * @param {Object} [options.state] - Initial state of database. You can use it to pre-init database Arrays/Objects to store your own data `default:` `{}`
 * @param {Function} [options.getSessionKey] - Function to get identifier for session from {@link https://telegraf.js.org/#/?id=context|Telegraf Context} (may implement it on your own)
 * @param {Object} [options.storage] - lowdb storage option for implementing your own storage read/write operations `default:` {@link LocalSession.storageFileSync|LocalSession.storageFileSync}
 * @param {Function} [options.storage.read] - lowdb storage read function, must return an object or a Promise
 * @param {Function} [options.storage.write] - lowdb storage write function, must return undefined or a Promise
 * @param {Object} [options.format] - lowdb storage format option for implementing your own database format for read/write operations
 * @param {Function} [options.format.serialize] - lowdb storage serialize function, must return data (usually string) `default:` {@link https://goo.gl/dmGpZd|JSON.stringify()}
 * @param {Function} [options.format.deserialize] - lowdb storage deserialize function, must return an object `default:` {@link https://goo.gl/wNy3ar|JSON.parse()}
 * @returns Instance of {@link LocalSession|LocalSession}
 */
class LocalSession {
  constructor (options = {}) {
    this.options = Object.assign({
      // TODO: Use storageFileAsync as default with support of Promise or Promise-like initialization, see: https://git.io/fA3ZN
      storage: LocalSession.storageFileSync,
      database: 'sessions.json',
      property: 'session',
      state: { },
      format: { },
      getSessionKey: (ctx) => {
        if (!ctx.from) return // should never happen

        let chatInstance
        if (ctx.chat) {
          chatInstance = ctx.chat.id
        } else if (ctx.updateType === 'callback_query') {
          chatInstance = ctx.callbackQuery.chat_instance
        } else { // if (ctx.updateType === 'inline_query') {
          chatInstance = ctx.from.id
        }
        return chatInstance + ':' + ctx.from.id
      }
    }, options)
    this.DB = undefined
    this._adapter = undefined
    // DISABLED: this.options.defaultValue = this.options.state // Backward compatability with old lowdb
    const defaultAdaptersOptions = Object.assign(
      { defaultValue: this.options.state },
      this.options.format.serialize ? { serialize: this.options.format.serialize } : {},
      this.options.format.deserialize ? { deserialize: this.options.format.deserialize } : {}
    )
    if (this.options.storage === LocalSession.storageMemory) {
      debug('Initiating: lowdb adapter: storageMemory')
      this._adapter = new LocalSession.storageMemory(this.options.database, defaultAdaptersOptions)
    } else if (this.options.storage === LocalSession.storageFileAsync) {
      debug('Initiating: lowdb adapter: storageFileAsync')
      this._adapter = new LocalSession.storageFileAsync(this.options.database, defaultAdaptersOptions)
    } else if (this.options.storage === LocalSession.storageFileSync) {
      debug('Initiating: lowdb adapter: storageFileSync')
      this._adapter = new LocalSession.storageFileSync(this.options.database, defaultAdaptersOptions)
    } else {
      debug('Initiating: lowdb adapter: custom storage/adapter')
      this._adapter = new this.options.storage(this.options.database, defaultAdaptersOptions)
    }
    debug('Initiating: lowdb instance')
    const DbInstance = lowdb(this._adapter)
    // If lowdb initiated with async (Promise) adapter
    if (isPromise(DbInstance)) {
      debug('DbInstance is Promise like')
      // TODO: Split it from constructor, because this code will produce glitches if async initiating may take too long time
      this.DB = DbInstance
      this.DB.then((DB) => {
        debug('DbInstance Promise resolved')
        this.DB = DB
        _initDB.call(this)
      })
    }
    // If lowdb initiated with sync adapter
    else {
      this.DB = DbInstance
      _initDB.call(this)
    }
  }

  /**
   * Get session key from {@link https://telegraf.js.org/#/?id=context|Telegraf Context}
   *
   * @param {Object} ctx - {@link https://telegraf.js.org/#/?id=context|Telegraf Context}
   * @returns {String} Session key in format `number:number` (chat.id:from.id)
   */
  getSessionKey (ctx) {
    this._called()
    return this.options.getSessionKey(ctx)
  }

  /**
   * Get session by it's key in database
   *
   * @param {String} key - Key which will be used to find associated session object
   * @returns {Object} Session data object or empty object if there's no session in database with this key
   */
  getSession (key) {
    this._called(arguments)
    const session = this.DB.get('sessions').getById(key).value() || {}
    debug('Session state', session)
    return session.data || {}
  }

  /**
   * Save session to database
   *
   * @param {String} key - Unique Key which will be used to store session object
   * @param {Object} data - Session data object (if empty, session will be removed from database)
   * @returns {Promise|Function} - Promise or Promise-like `.then()` function, with session object at 1-st argument
   */
  async saveSession (key, data) {
    this._called(arguments)
    if (!key) return
    // If there's no data provided or it's empty, we should remove session record from database
    if (this.DB._.isEmpty(data)) {
      debug('Removing session #', key)
      return this.DB.get('sessions').removeById(key).write()
    }
    debug('Saving session: %s = %o', key, data)
    /* eslint-disable brace-style */
    // If database has record, then just update it
    if (this.DB.get('sessions').getById(key).value()) {
      debug(' -> Updating')
      const session = await this.DB.get('sessions').updateById(key, { data: data }).write()
      // Check if lowdb Storage returned var type is Promise-like or just sync-driven data
      return session
    }
    // If no, so we should push new record into it
    else {
      debug(' -> Inserting')
      const session = await this.DB.get('sessions').push({ id: key, data: data }).write()
      // Check if lowdb Storage returned var type is Promise-like or just sync-driven data
      return session[0]
    }
    /* eslint-enable brace-style */
  }

  /**
   * Session middleware for use in Telegraf
   *
   * @param {String} [property] - Name of property in {@link https://telegraf.js.org/#/?id=context|Telegraf Context} where session object will be located (overrides `property` at {@link LocalSession} constructor)
   * @returns {Promise}
   */
  middleware (property = this.options.property) {
    const that = this
    return async (ctx, next) => {
      const key = that.getSessionKey(ctx)
      if (!key) return next()
      debug('Session key: %s', key)
      let session = that.getSession(key)
      debug('Session data: %o', session)
      // Assigning session object to the Telegraf Context using `property` as a variable name
      Object.defineProperty(ctx, property, {
        get: function () { return session },
        set: function (newValue) { session = Object.assign({}, newValue) }
      })
      // Make lowdb available in the Telegraf Context
      Object.defineProperty(ctx, `${property}DB`, {
        get: function () { return that.DB },
        set: function () { }
      })
      // Saving session object on the next middleware
      await next()

      this._called(arguments)
      debug('Next Middleware -> Key: %s | Session: %o', key, session)
      return that.saveSession(key, session)
    }
  }

  /**
   * lowdb storage named {@link https://git.io/vhM3Y|fileSync} before {@link https://git.io/vhM3Z|lowdb@0.17.0}
   *
   * @memberof! LocalSession
   * @name LocalSession.storagefileSync
   * @alias LocalSession.storageFileSync
   * @readonly
   */
  static get storagefileSync () {
    return storageFileSync
  }

  /**
   * lowdb storage/adapter named {@link https://git.io/vhMqc|FileSync}
   *
   * @memberof! LocalSession
   * @name LocalSession.storageFileSync
   * @global
   * @readonly
   */
  static get storageFileSync () {
    return storageFileSync
  }

  /**
   * lowdb storage named {@link https://git.io/vhM3m|fileAsync} before {@link https://git.io/vhM3Z|lowdb@0.17.0}
   *
   * @memberof! LocalSession
   * @name LocalSession.storagefileAsync
   * @alias LocalSession.storageFileAsync
   * @readonly
   */
  static get storagefileAsync () {
    return storageFileAsync
  }

  /**
   * lowdb storage/adapter named {@link https://git.io/vhMqm|FileAsync}
   *
   * @memberof! LocalSession
   * @name LocalSession.storageFileAsync
   * @global
   * @readonly
   */
  static get storageFileAsync () {
    return storageFileAsync
  }

  /**
   * lowdb storage/adapter named {@link https://git.io/vhMqs|Memory}
   *
   * @memberof! LocalSession
   * @name LocalSession.storageMemory
   * @global
   * @readonly
   */
  static get storageMemory () {
    return storageMemory
  }

  /**
   * lowdb {@link https://git.io/vhMOK|storage/adapter base} constructor (to extend it in your custom storage/adapter)
   *
   * @memberof! LocalSession
   * @name LocalSession.storageBase
   * @global
   * @readonly
   */
  static get storageBase () {
    return require('lowdb/adapters/Base')
  }

  /**
   * For Debugging purposes only - shows info about what, where & with what args was called
   *
   * @param {Object} args - Called function's arguments
   * @private
   */
  _called (args) {
    debug('Called function: \n\t-> %s \n\t-> Arguments: \n\t-> %o', ((new Error().stack).split('at ')[2]).trim(), this.DB._.values(args))
  }
}

function _initDB () {
  // Use ID based resources, so we can query records by ID (ex.: getById(), removeById(), ...)
  this.DB._.mixin(lodashId)
  // If database is empty, fill it with empty Array of sessions and optionally with initial state
  this.DB.defaults(Object.assign({ sessions: [] }, this.options.state)).write()
  debug('Initiating finished')
  return true
}

// Credits to `is-promise` package
function isPromise (obj) {
  return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'
}

/**
 * @overview {@link http://telegraf.js.org/|Telegraf} Session middleware for storing sessions locally (Memory/FileSync/FileAsync/...)
 * @module telegraf-session-local
 * @license MIT
 * @author Tema Smirnov <git.tema@smirnov.one>
 * @requires {@link https://www.npmjs.com/package/telegraf|npm: telegraf}
 * @requires {@link https://www.npmjs.com/package/lowdb|npm: lowdb}
 * @see {@link http://telegraf.js.org/|Telegraf} | {@link https://github.com/typicode/lowdb|lowdb}
 * @exports LocalSession
 */
module.exports = LocalSession
module.exports.isPromise = isPromise