lib/message/messageModel.js

'use strict';

const { CommonValidator } = require("../../common/validator");
const MessageModelSchemaFactory = require("./schema/messageModelSchema");

/**
 * The Bots MessageModel is a class for creating and validating a message structure based on the
 * Conversation Message Model (CMM), representing a message to or from a bot.  This class is used 
 * by the Bots Custom Components Conversation SDK, and can also be used independently of the SDK.
 * <p>
 * This class can be used in a server side Nodejs environment, or in the browser.
 * </p>
 * A MessageModel class instance can be instantiated using the constructor taking the payload
 * that represents the message.  The payload is then parsed and validated.
 * @memberof module:Lib
 * @alias MessageModel
 * @deprecated Use MessageFactory instead
 */
class MessageModel {
  /**
   * To create a MessageModel object using a javascript object representing the conversation message, 
   * or a string for plain text message.
   * <p>
   * The object is of Conversation Message Model (CMM) message type such as Text, Card, Attachment, 
   * Location, Postback, or Raw type.  This message object may be created using the static methods 
   * in this class.  Or the message object may be received from a sender, and a MessageModel
   * can then be created to validate the message object.
   * </p>
   * <p>
   * The payload will be validated.  If it is a valid message, messagePayload() will return the valid 
   * message object.  If not, the message content can be retrieved via payload().
   * </p>
   * To support older message format, the object can also be of the 'choice' type.
   * @constructor
   * @param {string|object} payload - The payload to be parsed into a MessageModel object
   */
  constructor(payload) {
    this._payload = payload;
    this._messagePayload = null;
    this._validationError = null;
    this._parse();
  }

  _parse() {
    if (this._payload) {
      if (this._payload.type) {
        if (this._payload.type === 'choice') {
          this._payload = MessageModel._parseLegacyChoice(this._payload);
        }
      } else {
        if (typeof this._payload === 'string') {
          this._payload = MessageModel.textConversationMessage(this._payload);
        } else {
          if (this._payload.choices) {
            this._payload = MessageModel._parseLegacyChoice(this._payload);
          } else if (this._payload.text && Object.keys(this._payload).length === 1) {
            this._payload = MessageModel.textConversationMessage(this._payload.text);
          }
        }
      }

      var result = MessageModel.validateConversationMessage(this._payload);
      if (result === true) {
        this._messagePayload = Object.assign({}, this._payload);
      } else {
        this._validationError = result;
      }
    }
  }

  /**
   * Retrieves the validated common message model payload.
   * @return {object} The common message model payload
   */
  messagePayload() {
    return this._messagePayload;
  }

  /**
   * If messagePayload() returns null or if isValid() is false, this method can be used to 
   * retrieve the payload that could not be converted to a Conversation Message Model (CMM) payload.
   * @return {object} The payload which may not comply to Conversation Message Model (CMM)
   */
  rawPayload() {
    return this._payload;
  }

  /**
   * returns if the instance contains a valid message according to Conversation Message Model (CMM)
   * @return {boolean} if the message conforms to Conversation Message Model (CMM)
   */
  isValid() {
    return (!!this._messagePayload);
  }

  /**
   * Retrieves the validation error messages, if any.  Use if messagePayload() returns null or 
   * isValid() is false, signifying validation errors.
   * @return {object} The validation error encountered when converting the payload to the 
   * Conversation Message Model (CMM).  The validation error object is produced by joi.
   */
  validationError() {
    return this._validationError;
  }

  static _parseLegacyChoice(payload) {
    if (payload.choices && payload.choices instanceof Array && payload.choices.length > 0) {
      var postbacks = payload.choices.map(function (choice) {
        return MessageModel.postbackActionObject(choice, null, choice);
      });
      return MessageModel.textConversationMessage(payload.text, postbacks);
    } else {
      return payload;
    }
  }

  /**
   * Static method to create a TextConversationMessage.
   * @return {object} A TextConversationMessage.
   * @param {string} text - The text of the message payload.
   * @param {object[]} [actions] - A list of actions related to the text.
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static textConversationMessage(text, actions, footerText, headerText, keywords) {
    var instance = {
      type: 'text',
      text: text
    };
    if (actions) {
      instance.actions = actions;
    }
    if (footerText) {
      instance.footerText = footerText;
    }
    if (headerText) {
      instance.headerText = headerText;
    }
    if (keywords) {
      instance.keywords = keywords;
    }
    return instance;
  }

  static _baseActionObject(type, label, imageUrl) {
    var instance = {
      type: type
    };
    if (label) {
      instance.label = label;
    }
    if (imageUrl) {
      instance.imageUrl = imageUrl;
    }
    return instance;
  }

  /**
   * Static method to create a postback Action.  A label or an imageUrl is required.
   * @return {object} A postbackActionObject.
   * @param {string} [label] - label of the action.
   * @param {string} [imageUrl] - image to show for the action.
   * @param {object|string} postback - object or string to send as postback if action is taken.
   * @param {string[]} [keywords] - array of keywords that can be used to trigger the postback action.
   * @param {boolean} [skipAutoNumber] - Boolean flag that can be used to exclude a postback action 
   * from auto-numbering. Only applicable when 'autoNumberPostbackActions' context variable or
   * 'autoNumberPostbackActions' component property is set to true.
   */
  static postbackActionObject(label, imageUrl, postback, keywords, skipAutoNumber){
    var instance = this._baseActionObject('postback', label, imageUrl);
    if (~['string', 'object'].indexOf(typeof postback)) {
      instance.postback = postback;
    }
    if (keywords) {
      instance.keywords = keywords;
    }
    // since false is default for skipAutoNumber, we only need to add it when value is true
    if (skipAutoNumber) {
      instance.skipAutoNumber = skipAutoNumber;
    }
    return instance;
  }

  /**
   * Static method to create a url Action.  A label or an imageUrl is required.
   * @return {object} A urlActionObject.
   * @param {string} [label] - label of the action.
   * @param {string} [imageUrl] - image to show for the action.
   * @param {string} url - url to open if action is taken.
   */
  static urlActionObject(label, imageUrl, url) {
    var instance = this._baseActionObject('url', label, imageUrl);
    instance.url = url;
    return instance;
  }

  /**
   * Static method to create a call Action.  A label or an imageUrl is required.
   * @return {object} A callActionObject.
   * @param {string} [label] - label of the action.
   * @param {string} [imageUrl] - image to show for the action.
   * @param {string} phoneNumber - phoneNumber to call if action is taken.
   */
  static callActionObject(label, imageUrl, phoneNumber) {
    var instance = this._baseActionObject('call', label, imageUrl);
    instance.phoneNumber = phoneNumber;
    return instance;
  }

  /**
   * Static method to create a location Action.  A label or an imageUrl is required.
   * @return {object} A locationActionObject.
   * @param {string} [label] - label of the action.
   * @param {string} [imageUrl] - image to show for the action.
   */
  static locationActionObject(label, imageUrl) {
    return this._baseActionObject('location', label, imageUrl);
  }

  /**
   * Static method to create a share Action.  A label or an imageUrl is required.
   * @return {object} A shareActionObject.
   * @param {string} [label] - label of the action.
   * @param {string} [imageUrl] - image to show for the action.
   */
  static shareActionObject(label, imageUrl) {
    return this._baseActionObject('share', label, imageUrl);
  }

  /**
   * Static method to create a card object for CardConversationMessage.
   * @return {object} A Card.
   * @param {string} title - The title of the card.
   * @param {string} [description] - The description of the card.
   * @param {string} [imageUrl] - URL of the image.
   * @param {string} [url] - URL for a hyperlink of the card.
   * @param {object[]} [actions] - A list of actions available for this card.
   */
  static cardObject(title, description, imageUrl, url, actions) {
    var instance = {
      title: title || ''
    };
    if (description) {
      instance.description = description;
    }
    if (imageUrl) {
      instance.imageUrl = imageUrl;
    }
    if (url) {
      instance.url = url;
    }
    if (actions) {
      instance.actions = actions;
    }
    return instance;
  }

  /**
   * Static method to create a CardConversationMessage.
   * @return {object} A CardConversationMessage.
   * @param {string} [layout] - 'vertical' or 'horizontal'.  Whether to display the cards 
   * horizontally or vertically.  Default is vertical.
   * @param {object[]} cards - The list of cards to be rendered.
   * @param {object[]} [actions] - A list of actions for the cardConversationMessage.
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static cardConversationMessage(layout, cards, actions, footerText, headerText, keywords) {
    var response = {
      type: 'card',
      layout: layout || 'vertical',
      cards: cards
    };
    if (actions) {
      response.actions = actions;
    }
    if (footerText) {
      response.footerText = footerText;
    }
    if (headerText) {
      response.headerText = headerText;
    }
    if (keywords) {
      response.keywords = keywords;
    }
    return response;
  }

  /**
   * Static method to create a TableHeaderColumn object.
   * @return {object} A TableHeaderColumn object.
   * @param {string} [label] - The label of the column header
   * @param {integer} [width] - The width of the column header (optional)
   * @param {string} [alignment] - The alignment of the column header label (left, right or center, defaults to left)
   */
  static tableHeaderColumn(label, width, alignment) {
    var response = {
      label: label,      
      alignment: alignment || 'left'
    };
    if (width) {
      response.width = width;
    }
    return response;
  }

  /**
   * Static method to create a TableColumn object.
   * @return {object} A TableColumn object.
   * @param {object} [value] - The value of the column
   * @param {string} [alignment] - The alignment of the column value (left, right or center, defaults to left)
   * @param {string} [displayType] - The display type (text or link, defaults to text)
   * @param {string} [linkLabel] - The label used when the displayType is set to 'link'.
   */
  static tableColumn(value, alignment, displayType, linkLabel) {
    var response = {
      alignment: alignment || 'left',
      displayType: displayType || 'text'
    };
    if (value) {
      response.value = value;
    }
    if (linkLabel) {
      response.linkLabel = linkLabel;
    }
    return response;
  }

  /**
   * Static method to create a FormField object.
   * @return {object} A FormField object.
   * @param {string} [label] - The label of the form field
   * @param {object} [value] - The value of the field
   * @param {string} [displayType] - The display type (text or link, defaults to text)
   * @param {string} [linkLabel] - The label used when the displayType is set to 'link'.
   */
  static formField(label, value, displayType, linkLabel) {
    var response = {
      label: label,
      displayType: displayType || 'text'
    };
    if (value) {
      response.value = value;
    }
    if (linkLabel) {
      response.linkLabel = linkLabel;
    }
    return response;
  }

  /**
   * Static method to create a TableRow object.
   * @return {object} A TableRow object.
   * @param {object[]} [columns] - The columns in the row, can be created with tableColumn function
   */
  static tableRow(columns) {
    var response = {
      fields: columns 
    };
    return response;
  }

  /**
   * Static method to create a Form object.
   * @return {object} A Form object.
   * @param {object[]} [fields] - The fields in the form, can be created with formField function
   * @param {string} [title] - The title of the form object
   * @param {object[]} [actions] - A list of actions added to the form
   */
  static form(fields, title, actions) {
    var response = {
      fields: fields 
    };
    if (title) {
      response.title = title;
    }
    if (actions) {
      response.actions = actions;
    }
    return response;
  }

  /**
   * Static method to create a PaginationInfo object.
   * @return {object} A PaginationInfo object.
   * @param {integer} [totalCount] - The total number of items that are paginated
   * @param {integer} [rangeSize] - The number of items shown at once
   * @param {integer} [rangeStart] - The current range start index within the list of items
   * @param {string} [status] - Pagination status message
   */
  static paginationInfo(totalCount, rangeSize, rangeStart, status) {
    var response = {
      totalCount: totalCount,
      rangeSize: rangeSize,
      rangeStart: rangeStart
    };
    if (status) {
      response.status = status;
    } 
    return response;
  }

  /**
   * Static method to create a TableConversationMessage.
   * @return {object} A TableConversationMessage.
   * @param {object[]} [headings] - The table header columns, can be created with tableHeaderColumn function
   * @param {object[]} [rows] - The table rows, can be created with tableRow function
   * @param {object[]} [paginationInfo] - The pagination info, can be created with the paginationInfo function
   * @param {object[]} [actions] - A list of actions added to the message
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static tableConversationMessage(headings, rows, paginationInfo, actions, footerText, headerText, keywords) {
    var response = {
      type: 'table',
      headings: headings || [],
      rows: rows || []
    };
    if (paginationInfo) {
      response.paginationInfo = paginationInfo;
    } else {
      // default to no pagination
      let count = (rows || []).length
      response.paginationInfo = this.paginationInfo(count, count, 0);
    }
    if (actions) {
      response.actions = actions;
    }
    if (footerText) {
      response.footerText = footerText;
    }
    if (headerText) {
      response.headerText = headerText;
    }
    if (keywords) {
      response.keywords = keywords;
    }
    return response;
  }

  /**
   * Static method to create a FormConversationMessage.
   * @return {object} A FormConversationMessage.
   * @param {object[]} [forms] - The list of forms, can be created with form function
   * @param {integer} [formColumns] - The number of columns used in the form layout, defaults to 1
   * @param {object[]} [paginationInfo] - The pagination info, can be created with the paginationInfo function
   * @param {object[]} [actions] - A list of actions added to the message
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static formConversationMessage(forms, formColumns, paginationInfo, actions, footerText, headerText, keywords) {
    var response = {
      type: 'form',
      forms: forms,
      formColumns: formColumns || 1
    };
    if (paginationInfo) {
      response.paginationInfo = paginationInfo;
    } else {
      // default to no pagination
      let count = (forms || []).length
      response.paginationInfo = this.paginationInfo(count, count, 0);
    }
    if (actions) {
      response.actions = actions;
    }
    if (footerText) {
      response.footerText = footerText;
    }
    if (headerText) {
      response.headerText = headerText;
    }
    if (keywords) {
      response.keywords = keywords;
    }
    return response;
  }

  /**
   * Static method to create a TableFormConversationMessage.
   * @return {object} A TableFormConversationMessage.
   * @param {object[]} [headings] - The table header columns, can be created with tableHeaderColumn function
   * @param {object[]} [rows] - The table rows, can be created with tableRow function
   * @param {object[]} [forms] - The list of forms, can be created with form function
   * @param {integer} [formColumns] - The number of columns used in the form layout, defaults to 1
   * @param {string} [showFormButtonLabel] - The label used for the button to open the form when the form is displayed in a dialog (Slack only) 
   * @param {object[]} [paginationInfo] - The pagination info, can be created with the paginationInfo function
   * @param {object[]} [actions] - A list of actions added to the message
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static tableFormConversationMessage(headings, rows, forms, formColumns, showFormButtonLabel, paginationInfo, actions, footerText, headerText, keywords) {
    var response = {
      type: 'tableForm',
      headings: headings,
      rows: rows,
      forms: forms,
      formColumns: formColumns || 1,
      paginationInfo: paginationInfo
    };
    if (paginationInfo) {
      response.paginationInfo = paginationInfo;
    } else {
      // default to no pagination
      let count = (rows || []).length
      response.paginationInfo = this.paginationInfo(count, count, 0);
    }
    if (showFormButtonLabel) {
      response.showFormButtonLabel = showFormButtonLabel;
    }
    if (actions) {
      response.actions = actions;
    }
    if (footerText) {
      response.footerText = footerText;
    }
    if (headerText) {
      response.headerText = headerText;
    }
    if (keywords) {
      response.keywords = keywords;
    }
    return response;
  } 

  /**
   * Static method to create an AttachmentConversationMessage
   * @return {object} An AttachmentConversationMessage.
   * @param {string} type - type of attachment - file, image, video or audio.
   * @param {string} url - the url of the attachment.
   * @param {object[]} [actions] - A list of actions for the attachmentConversationMessage.
   * @param {string} [footerText] - The footerText to be added at the bottom of the message.
   * @param {string} [headerText] - The headerText to be added at the top of the message.
   * @param {object[]} [keywords] - A list of postback keywords that can be created with the postbackKeyword function
   */
  static attachmentConversationMessage(type, url, actions, footerText, headerText, keywords) {
    var attachment = {
      type: type,
      url: url
    };
    var response = {
      type: 'attachment',
      attachment: attachment
    };
    if (actions) {
      response.actions = actions;
    }
    if (footerText) {
      response.footerText = footerText;
    }
    if (headerText) {
      response.headerText = headerText;
    }
    if (keywords) {
      response.keywords = keywords;
    }
    return response;
  }

  /**
   * Static method to create a LocationConversationMessage.
   * @return {object} A LocationConversationMessage.
   * @param {number} latitude - The latitude.
   * @param {number} longitude - The longitude.
   * @param {string} [title] - The title for the location.
   * @param {string} [url] - A url for displaying a map of the location.
   * @param {object[]} [actions] - A list of actions for the locationConversationMessage.
   */
  static locationConversationMessage(latitude, longitude, title, url, actions) {
    var location = {
      latitude: latitude,
      longitude: longitude
    };
    if (title) {
      location.title = title;
    }
    if (url) {
      location.url = url;
    }
    var response = {
      type: 'location',
      location: location
    };
    if (actions) {
      response.actions = actions;
    }
    return response;
  }

  /**
   * Static method to create a postackConversationMessage
   * @return {object} A PostbackConversationMessage.
   * @param {object|string} postback - object or string to send as postback.
   * @param {string} [label] - The label associated with the postback.
   * @param {object[]} [actions] - A list of actions for the postbackConversationMessage.
   */
  static postbackConversationMessage(postback, label, actions) {
    var response = {
      type: 'postback',
      postback: postback
    };
    if (label) {
      response.text = label;
    }
    if (actions) {
      response.actions = actions;
    }
    return response;
  }

  /**
   * Static method to create a keyword for a postack payload that is not associated to a postback action button
   * @return {object} A Keyword object.
   * @param {string[]} [keywords] - array of keywords that can be used to trigger the postback action.
   * @param {object|string} postback - object to send as postback if keyword is entered
   * @param {boolean} [skipAutoNumber] - Boolean flag that can be used to exclude the keyword from autoNumbering
   */
  static postbackKeyword(keywords, postback, skipAutoNumber) {
    var keyword = {
      keywords: keywords,
      postback: postback,
      skipAutoNumber: skipAutoNumber || false
    };
    return keyword;
  }

  /**
   * Static method to create a RawConversationMessage.
   * @return {object} A RawConversationMessage.
   * @param {object} payload - The raw (channel-specific) payload,
   */
  static rawConversationMessage(payload) {
    return {
      type: 'raw',
      payload: payload
    };
  }

  /**
   * Static method to add channel extensions to a payload object.
   * @return {object} The message object with channel extensions.
   * @param {object} message - The message, card or action object to add channel extensions to.
   * @param {string} channel - The channel type ('facebook', 'webhook', etc) to set extensions on.
   * @param {object} extensions - The channel-specific extensions to be added.
   */
  static addChannelExtensions(messageObject, channel, extensions) {
    if (messageObject && channel && extensions) {
      if (!messageObject.channelExtensions) {
        messageObject.channelExtensions = {};
      }
      messageObject.channelExtensions[channel] = (messageObject.channelExtensions[channel] ? Object.assign(messageObject.channelExtensions[channel], extensions) : extensions);
    }
    return messageObject;
  }

  /**
   * Static method to add global actions to a message payload object. This method replaces any existing global actions.
   * @return {object} A ConversationMessage with global actions.
   * @param {object} message - The message to add global actions to.
   * @param {object} globalActions - The global actions to be added.
   */
  static addGlobalActions(message, globalActions) {
    if (message && globalActions) {
      message.globalActions = globalActions;
    }
    return message;
  }

  /**
   * Static method to add a global action to a message payload object. 
   * @return {object} A ConversationMessage with global actions.
   * @param {object} message - The message to add the global action to.
   * @param {object} globalAction - The global action to be added.
   */
  static addGlobalAction(message, globalAction) {
    let globalActions = message.globalActions || [];
    globalActions.push(globalAction);
    message.globalActions = globalActions;
    return message;
  }

  /**
   * Static method to validate a common ConversationMessage
   * @return {boolean|object} true if valid; return Validation Error object (error & value) if invalid
   * @param {object} payload - The payload object to be verified
   */
  static validateConversationMessage(payload) {
    const result = CommonValidator.validate(MessageModelSchemaFactory, payload);
    if (result && !result.error) {
      return true;
    } else {
      return result;
    }
  }
}

module.exports = {
  MessageModel,
}