lib/component/sdk.js

/* eslint-disable no-prototype-builtins */
'use strict';

const { BaseContext } = require("./baseContext");
const ComponentRequestSchemaFactory = require("./schema/componentRequestSchema");
const { MessageModel } = require("../message/messageModel");

// fix for MIECS-23476, we need to return "2.0" until all customers have migrated to 20.05 which
// no longer checks whether version returned is a valid platform version
// const sdkVersion = '2.0';
const sdkVersion = require('../../package.json').version;

// Response template
const RESPONSE = {
  platformVersion: undefined,
  context: undefined,
  action: undefined,
  keepTurn: true,
  transition: false,
  error: false,
  modifyContext: false
};

// Variable types supported by the dialog engine
const CONST = {
  SYSTEM_INVALID_USER_INPUT: 'system.invalidUserInput',
};

/**
 * The Bots Node SDK uses this class to receive bot requests, which is provided
 * to the custom component invocation.
 * <p>
 * It offers a comprehensive interface to reading context for the invocation
 * as well as changing variables and sending results back to the dialog engine.
 * </p>
 * @memberof module:Lib
 * @extends BaseContext
 * @alias CustomComponentContext
 * @example <caption>Context object used to invoke Custom Component</caption>
 * 
 * const MyCustomComponent = {
 *   metadata: () => ({name: 'hello'}),
 *   invoke: async (context) => {
 *     // use conversation instance methods to respond, set variables, etc.
 *     context.reply('Hello!');
 *     context.transition();
 *   }
 * }
 */
class CustomComponentContext extends BaseContext {
  /**
   * @param {object} request - The request body
   */
  constructor(request) {
    // Initilize the response, filling in platformVersion, context/vars
    // from the incoming request as needed.
    const response = Object.assign({}, RESPONSE, { platformVersion: request.platformVersion });
    super(request, response, ComponentRequestSchemaFactory);

    // Reset system.invalidUserInput variable if set to true.  Requested by runtime to do this in sdk
    this._resetInvalidUserInput();
  }

  /**
   * Retrieves the request body.
   * @return {object} The request body.
   */
  request() {
    return super.getRequest();
  }

  /**
   * Retrieves the bot id.
   * @return {string} The bot id.
   */
  botId() {
    return this.request().botId;
  }

  /**
   * Retrieves the sdk version.
   * @return {string} The sdk version.
   */
  static sdkVersion() {
    return sdkVersion;
  }

  /**
   * Retrieves the platform version of the request.
   * @return {string} The platform version.
   */
  platformVersion() {
    return this.request().platformVersion;
  }

  /**
   * Retrieves the raw payload of the current input message.
   * @return {object} The raw payload.
   */
  rawPayload() {
    return this.request().message.payload;
  }

  /**
   * Retrieves the payload of the current input message in the common message format.
   * @return {object} The common message payload.
   */
  messagePayload() {
    return this.request().message.messagePayload;
  }

  /**  
   * Retrieves the payload of the current input message. For backward compatibility purposes.  
   * However, the payload returned may be in the new message format.
   * @return {object} The message payload.
   * @deprecated to be removed in favor of rawPayload() and messagePayload()
   * @private
   */
  payload() {
    this.logger().warn("conversation SDK payload() is deprecated in favor of messagePayload()"); 
    return this.rawPayload();
  }

  /**
   * Retrieves the channel type of the current input message.
   * @return {string} The channel type - facebook, webhook, test, etc.
   */
  channelType() {
    return this.request().message.channelConversation.type;
  }

  /**
   * Retrieves the channel Id of the current input message.
   * @return {string} The channel id.
   */
  channelId() {
    return this.request().message.channelConversation.channelId;
  }


  /**
   * Retrieves the userId for the current input message.
   * @return {string} The userId.
   */
  userId() {
    return this.request().message.channelConversation.userId;
  }

  /**
   * Retrieves the sessionId for the current input message.
   * @return {string} The sessionId.
   */
  sessionId() {
    return this.request().message.channelConversation.sessionId;
  }

  // retrieve v1.0 facebook postback
  _postback10() {
    const rawPayload = this.rawPayload();
    if (rawPayload && this.channelType() === 'facebook') {
      if (rawPayload.hasOwnProperty('postback') && rawPayload.postback.hasOwnProperty('payload')) {
        return rawPayload.postback.payload;
      }
    }
    return null;
  }

  /**
   * Retrieves the postback of the current input message.
   * If the input message is not a postback, this will return null.
   * @return {object} The postback payload.
   */
  postback() {
    let postback = null;

    const messagePayload = this.messagePayload();
    if (messagePayload && messagePayload.postback) {
      postback = messagePayload.postback;
    }
    if (!postback) {
      postback = this._postback10();
    }
    return postback;
  }

  // return v1.0 facebook text and quick_reply text
  _text10() {
    const rawPayload = this.rawPayload();
    if (rawPayload && this.channelType() === 'facebook') {
      if (rawPayload.hasOwnProperty('message')) {
        if (rawPayload.message.hasOwnProperty('quick_reply') && rawPayload.message.quick_reply.hasOwnProperty('payload')) {
          return rawPayload.message.quick_reply.payload;
        } else if (rawPayload.message.hasOwnProperty('text')) {
          return rawPayload.message.text;
        }
      }
    }
    return null;
  }

  /**
   * Retrieves the text of the current input message.
   * Eventually not all messages will have a text value, in which case
   * this will return null.
   * @return {string} The text of the input message.
   */
  text() {
    let text = null;

    const messagePayload = this.messagePayload();
    if (messagePayload) {
      if (messagePayload.text) {
        text = messagePayload.text;
      } else {
        const postback = this.postback();
        if (postback && typeof postback === 'string') {
          text = postback;
        }
      }
    }
    if (!text) {
      text = this._text10();
    }
    return text;
  }

  /**
   * Retrieves the attachment of the current input message.
   * If the input message is not an attachment, this will return null.
   * @return {object} The attachment.
   */
  attachment() {
    let attachment = null;

    const messagePayload = this.messagePayload();
    if (messagePayload && messagePayload.attachment) {
      attachment = messagePayload.attachment;
    }
    return attachment;
  }

  /**
   * Retrieves the location of the current input message.
   * If the input message does not contain a location, this will return null.
   * @return {object} The location.
   */
  location() {
    let location = null;

    const messagePayload = this.messagePayload();
    if (messagePayload && messagePayload.location) {
      location = messagePayload.location;
    }
    return location;
  }

  /**
   * Retrieves the properties defined for the current state.
   * @return {object} The properties
   */
  properties() {
    return this.request().properties || {};
  }

  /**
   * Returns the MessageModel class for creating or validating messages to or from bots.
   * @return {MessageModel} The MessageModel class
   * @deprecated Use getMessageFactory() instead
   */
  MessageModel() {
    return super.getMessageModel();
  }

  /**  
   * Sets the action to return from this component, which will determine the
   * next state in the dialog.
   *
   * @param {string} a - action name
   * @deprecated to be removed in favor of transition(action)
   * @private
   */
  action(a) {
    this.logger().warn("conversation SDK action() is deprecated in favor of transition(action)"); 
    if (a === undefined) {
      return this.response().action;
    }

    this.response().action = a;
    return this;
  }

  /**
     * Call this method if the input is not understood, and this would allow the bots runtime to 
     * handle the issue.  The bots runtime may just display the message to the user and execute the same component again, or
     * it may try to interpret the input and process differently.
     * @param {object|string|MessageModel} [r] - optional payload to be sent to user.  payload could also be a string for text response
     */
  invalidUserInput(r) {
    this.variable(CONST.SYSTEM_INVALID_USER_INPUT, true);
    this.reply(r||'Input not understood.  Please try again');
    return this;
  }

  _resetInvalidUserInput() {
    if (this.variable(CONST.SYSTEM_INVALID_USER_INPUT) === true) {
      this.variable(CONST.SYSTEM_INVALID_USER_INPUT, false);
    }
  }
  
  /** 
   * Set "exit" to true when your component has replies it wants to send to
   * the client.
   * <p>
   * The SDK's "reply" function automatically sets "exit" to true, but
   * if you manually modify the response to send replies then you will need
   * to set this explicitly.
   * </p>
   * @private
   * @deprecated to be removed in favor of keepTurn(boolean)
   */
  exit(e) {
    this.logger().warn("conversation SDK exit() is deprecated in favor of keepTurn(boolean)"); 
    this.response().keepTurn = !e;
    return this;
  }

  /**
   * "keepTurn" is used to indicate if the Bot/component should send the next replies, or
   * or if the Bot/component should wait for user input (keepTurn = false).
   * <p>
   * The SDK's "reply" function automatically sets "keepTurn" to false.
   * </p>
   * @param {boolean} [k] - whether to keep the turn for sending more replies
   */
  keepTurn(k) {
    this.response().keepTurn = (typeof k === "undefined" ? true : !!k);
    return this;
  }

  /**
   * "releaseTurn" is the shorthand for keepTurn(false)
   * @param {boolean} [k] - whether to keep the turn for sending more replies
   */
  releaseTurn(k) {
    this.response().keepTurn = (typeof k === "undefined" ? false : !k);
    return this;
  }

  /**  
   * Set "done" to true when your component has completed its logic and
   * the dialog should transition to the next state.
   * <p>
   * This is only meaningful when you are sending replies (ie you have also
   * set "exit" to true).  If you are not sending replies ("exit" is false,
   * the default) then "done" is ignored; the dialog always moves to the next
   * state.
   * </p>
   * If "exit" is true (replies are being sent), then leaving "done" as false
   * (the default) means the dialog will stay in this state after sending
   * the replies, and subsequent user input will come back to this component.
   * This allows a component to handle a series of interactions within itself,
   * however the component is responsible for keeping track of its own state
   * in such situations.
   * <p>
   * Setting "done" to true will transition to the next state/component after
   * sending the replies.
   * </p>
   * @private
   * @deprecated to be removed in favor of transition()
   */
  done(d) {
    this.logger().warn("conversation SDK done() is deprecated in favor of transition()"); 
    this.response().transition = !!d;
    return this;
  }

  /**
   * Call <code>transition()</code> when your component has completed its logic and
   * the dialog should transition to the next state, after replies (if any) are sent.
   * <p>
   * If <code>transition()</code> is not called, the dialog will stay in this state after sending
   * the replies (if any), and subsequent user input will come back to this component.
   * This allows a component to handle a series of interactions within itself,
   * however the component is responsible for keeping track of its own state
   * in such situations.
   * </p>
   * <code>transition()</code> will cause the dialog to transition to the next state.
   * transition(outcome) will set te outcome of the component that would be used to
   * determine the next state to transition to.
   * @param {string} [t] - outcome of component
   */
  transition(t) {
    this.response().transition = true;
    if (typeof t !== 'undefined') {
      this.response().action = t;
    }
    return this;
  }

  /**
   * Sets the error flag on the response.
   * @param {boolean} e - sets error if true
   */
  error(e) {
    this.response().error = !!e;
    return this;
  }

  /**
   * Adds a reply to be sent back to the user. May be called multiple times to send multiple replies in a given response.  
   * Automatically sets the <code>keepTurn</code> as false.
   * </p>
   * @param {object} payload - can take a string message, a message created by the MessageFactory, or a message created by 
   * the deprecated MessageModel.
   * @param {object} [channelConversation] - to override the default channelConversation from request
   * @returns the message payload in JSON format
   */
  reply(payload, channelConversation) {
    var response = {
      tenantId: this.request().message.tenantId,
      channelConversation: channelConversation || Object.assign({}, this.request().message.channelConversation)
    };
    var messagePayload =  super.constructMessagePayload(payload);
    if (messagePayload) {
      response.messagePayload = messagePayload;
    } else {
      // is invalid raw message payload, keep for backwards compatibility
      var rawMessagePayload = MessageModel.rawConversationMessage(payload);
      var messageModel = new MessageModel(rawMessagePayload);
      response.payload = messageModel.rawPayload();  
    }

    this.response().messages = this.response().messages || [];
    this.response().messages.push(response);

    // "keepTurn" false which signals to the engine to send replies and wait for user input
    this.keepTurn(false);

    return this;
  }


  // The HTTP response body
  response() {
    return super.getResponse();
  }

  // BUGBUG: workaround for https://jira.oraclecorp.com/jira/browse/MIECS-2748
  resolveVariable(variable) {
    return variable.startsWith('${') ? null : variable;
  }

  /** 
   * When expecting an out of band conversation continuation, such as a
   * user following the OAuth flow, completing a form and hitting submit, or
   * a human support agent or other third party sending a message, issue a
   * limited use token to allow calling back into Bots via the generic callback
   * endpoint.
   * The provided token should be a UUID or other unique and random number.  By setting it
   * here in the response the Bot will await a reply with that token and use it to
   * thread the message back into the current conversation with that user.
   * @param {string} callbackToken - token generated by you to allow reauthentication back 
   * into this conversation.  Should be unique, like userId + random.  It is ok to reissue 
   * the same token for the same conversation.
   * @private
   */
  setCallbackToken(callbackToken) {
    this.response().callbackToken = (typeof callbackToken === "undefined" ? null : callbackToken);
    return this;
  }
}

module.exports = {
  CustomComponentContext,
}