"use strict";
module.exports = function(sharedLogger){
var logger;
if (sharedLogger) {
logger = sharedLogger;
} else {
logger = console;
logger.info("sdk.js create console logger");
}
const Joi = require('joi');
const sdkVersion = '1.1';
const MessageModel = require("./MessageModel")(Joi);
// Response template
const RESPONSE = {
platformVersion: undefined,
context: undefined,
action: undefined,
keepTurn: true,
transition: false,
error: false,
modifyContext: false
};
const VARIABLE = {
type: "string",
entity: false
};
// Oracle REST Standard error structure
const ERROR = {
type: 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1',
title: undefined,
detail: undefined,
'o:errorCode': undefined,
'o:errorDetails': undefined
};
// Variable types supported by the dialog engine
const PRIMITIVE_TYPES = ['int', 'float', 'double', 'boolean', 'string', 'map', 'list'];
const NLPRESULT_TYPE = 'nlpresult';
/**
* Wrapper object for accessing nlpresult
*/
const NLPResult = class {
constructor(nlpresult) {
this._nlpresult = nlpresult;
}
/**
* Returns matches for the specified entity; may be an empty collection.
* If no entity is specified, returns the map of all entities.
* @param {string} [entity] - name of the entity
* @return {object} The entity match result.
*/
entityMatches(entity) {
if (!this._nlpresult) {
return entity === undefined ? {} : [];
}
if (entity === undefined) {
// Retrieving entityMatches collection, or an empty collection if none
return this._nlpresult.entityMatches ? this._nlpresult.entityMatches : {};
}
else {
if (this._nlpresult.entityMatches) {
return this._nlpresult.entityMatches[entity] ? this._nlpresult.entityMatches[entity] : [];
}
else {
return [];
}
}
}
// TODO: accessors for other properties
};
/**
* The Bots JS SDK exports a class that wraps an invocation to the custom component.
* Create it with the request payload (as an object) that was used to invoke
* the component.
*
* It offers a friendlier interface to reading context for the invocation
* as well as changing variables and sending results back to the diaog engine.
*/
const ComponentInvocation = class {
/**
* @constructor
* @param {object} requestBody - The request body
*/
constructor(requestBody) {
const validationResult = validateRequestBody(requestBody);
if (validationResult.error) {
let err = new Error('Request body malformed');
err.name = 'badRequest';
err.details = createErrorDetails('Request body malformed',
JSON.stringify(validationResult.error),
'BOTS-1000',
{ requestBody: requestBody });
throw err;
}
this._request = requestBody; // cache a ref to the request body
// Initilize the response, filling in platformVersion, context/vars
// from the incoming request as needed.
this._response = Object.assign({}, RESPONSE);
this._response.platformVersion = this._request.platformVersion;
this._response.context = Object.assign({}, this._request.context);
this._response.context.variables = this._response.context.variables || {};
}
/**
* Retrieves the request body.
* @return {object} The request body.
*/
request() {
return this._request;
}
/**
* 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 logger so the component can use the shared logger for logging. The shared logger should support the methods log, info, warn, error and trace.
* @return {object} The logger.
*/
logger() {
return logger;
}
/**
* 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()
*/
payload() {
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 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();
}
logger.info('SDK: Retrieving request postback=' + postback);
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 {
var postback = this.postback();
if (postback && typeof postback === 'string') {
text = postback;
}
}
}
if (!text) {
text = this._text10();
}
logger.info('SDK: Retrieving request text=' + text);
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;
}
logger.info('SDK: Retrieving request attachment=' + 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 attachment.
*/
location() {
let location = null;
const messagePayload = this.messagePayload();
if (messagePayload && messagePayload.location) {
location = messagePayload.location;
}
logger.info('SDK: Retrieving request location=' + location);
return location;
}
/**
* Retrieves the properties defined for the current state.
* @return {object} The properties
*/
properties() {
return this.request().properties;
}
/**
* Read or write variables defined in the current flow.
* It is not possible to change the type of an existing variable through
* this method. It is the caller's responsibility to ensure that the
* value being set on a variable is of the correct type. (e.g. entity,
* string or other primitive, etc).
*
* A new variable can be created. However, since the variable is not
* defined in the flow, using it in the flow subsequently may be flagged
* for validation warnings.
*
* This function takes a variable number of arguments.
*
* The first form:
* variable(name);
* reads the variable called "name", returning its value.
*
* The second form:
* variable(name, value);
* writes the value "value" to the variable called "name".
*
* @param {string} name - The name of variable to be set or read
* @param {string} [value] - value to be set for variable (optional)
*/
variable(name, value) {
var context = this._response.context;
var scopeName = null;
var nameToUse = name;
var index = name.indexOf(".");
if (index > -1)
{
scopeName = name.substring(0, index);
var possibleScope = context;
while (possibleScope !== null)
{
if (possibleScope.scope === scopeName)
{
context = possibleScope;
nameToUse = name.substring(index+1, name.length);
break;
} else
{
possibleScope = possibleScope.parent;
}
}
}
if (value === undefined) {
if (!context.variables || !context.variables.hasOwnProperty(nameToUse)) {
return undefined;
}
return context.variables[nameToUse].value;
}
else {
logger.info('SDK: About to set variable ' + name);
if (!context.variables)
{
context.variables = {};
}
if (!context.variables[nameToUse])
{
logger.info('SDK: Creating new variable ' + nameToUse);
context.variables[nameToUse] = Object.assign({}, VARIABLE);
}
context.variables[nameToUse].value = value;
this._response.modifyContext = true;
logger.info('SDK: Setting variable ' + JSON.stringify(context.variables[nameToUse]));
return this;
}
}
MessageModel() {
return MessageModel;
}
/**
* Returns an NLPResult helper object for working with nlpresult variables.
* See the NLPResult documentation for more information.
*
* You may specify a particular nlpresult by name (if you have multiple
* nlpresult variables defined in the flow), or omit the name if you
* only have 1 nlpresult.
*
* @param {string} [nlpVariableName] - variable to be given the nlpResult
* @return {NLPResult} The nlp resolution result.
*/
nlpResult(nlpVariableName) {
if (nlpVariableName === undefined) {
for (let name in this._response.context.variables) {
if (this._response.context.variables[name].type === NLPRESULT_TYPE) {
logger.info('SDK: using implicitly found nlpresult=' + name);
nlpVariableName = name;
break;
}
}
if (nlpVariableName === undefined) {
throw new Error('SDK: no nlpresult variable present');
}
}
const nlpVariable = this.variable(nlpVariableName);
if (nlpVariable === undefined) {
throw new Error('SDK: undefined var=' + nlpVariableName);
}
if (this._response.context.variables[nlpVariableName].type !== NLPRESULT_TYPE) {
throw new Error('SDK: var=' + nlpVariableName + ' not of type nlpresult');
}
return new NLPResult(nlpVariable);
}
/**
* 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)
*/
action(a) {
if (a === undefined) {
return this._response.action;
}
this._response.action = a;
return this;
}
/**
* Set "exit" to true when your component has replies it wants to send to
* the client.
*
* 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.
*
* @deprecated to be removed in favor of keepTurn(boolean)
*/
exit(e) {
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).
*
* The SDK's "reply" function automatically sets "keepTurn" to false.
* @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.
*
* 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.
*
* 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.
*
* Setting "done" to true will transition to the next state/component after
* sending the replies.
*
* @deprecated to be removed in favor of transition()
*/
done(d) {
this._response.transition = !!d;
return this;
}
/**
* Call "transition()" when your component has completed its logic and
* the dialog should transition to the next state, after replies (if any) are sent.
*
* If transition() 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.
*
* @param {string} [t] - outcome of component
* transition() 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.
*
*/
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
* "keepTurn" as false.
*
* reply can take a string payload, an object payload or a MessageModel payload. A string or object payload will be parsed
* into a MessageModel payload. If the MessageModel payload has a valid common message format, then reply will use it as
* messagePayload, else it will use the payload to create a rawConversationMessage (see MessageModel) as messagePayload.
* @param {object|string|MessageModel} payload - payload to be sent back. payload could also be a string for text response
* @param {object} [channelConversation] - to override the default channelConversation from request
*/
reply(payload, channelConversation) {
var response = {
tenantId: this._request.message.tenantId,
channelConversation: channelConversation || Object.assign({}, this._request.message.channelConversation)
};
var messageModel;
if (payload instanceof MessageModel) {
logger.info('messageModel payload provided', payload);
messageModel = payload;
} else {
logger.info('creating messageModel with payload:', payload);
messageModel = new MessageModel(payload);
}
if (messageModel.isValid()) {
logger.info('valid messageModel');
response.messagePayload = messageModel.messagePayload();
} else {
logger.info('message model validation error:',messageModel.validationError());
logger.info('using rawPayload');
var rawMessagePayload = MessageModel.rawConversationMessage(payload);
messageModel = new MessageModel(rawMessagePayload);
if (messageModel.isValid()) {
logger.info('valid messageModel for rawMessagePayload');
response.messagePayload = messageModel.messagePayload();
} else {
logger.info('message model validation error:',messageModel.validationError());
logger.info('using payload instead of messagePayload');
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 this._response;
}
// BUGBUG: workaround for https://jira.oraclecorp.com/jira/browse/MIECS-2748
resolveVariable(variable) {
return variable.startsWith('${') ? null : variable;
}
// BUGBUG: workaround for https://jira.oraclecorp.com/jira/browse/MIECS-2750
reformatDate(date) {
return date ? date.replace(/,/g, '') : null;
}
/**
* 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.
*/
setCallbackToken(callbackToken) {
this._response.callbackToken = (typeof callbackToken === "undefined" ? null : callbackToken);
return this;
}
};
function createErrorDetails(title, detail, errorCode, errorDetails) {
const details = Object.assign({}, ERROR);
details.title = title;
details.detail = detail;
details['o:errorCode'] = errorCode;
details['o:errorDetails'] = errorDetails;
return details;
}
function validateRequestBody(reqBody) {
// Joi does not seem to support recursive schemas.
const contextSchema = Joi.object().keys({
variables: Joi.object().pattern(/(.*)/,
Joi.object().keys({
entity: Joi.boolean().required(),
type: Joi.alternatives().when('entity', {
is: true,
then: Joi.object().keys({
type: Joi.string().required(),
name: Joi.string().required(),
patternExpression: Joi.string().allow(null),
parentEntity: Joi.any(),
ruleParameters: Joi.any(),
enumValues: Joi.when('type', { is: 'ENUMVALUES', then: Joi.string().required(), otherwise: Joi.any() })
}).required(),
otherwise: Joi.string().required() }),
value: Joi.any().allow(null)
})),
parent: Joi.object().allow(null)
});
const messageSchema = Joi.object().keys({
type: Joi.string().optional(),
payload: Joi.any().optional(),
messagePayload: Joi.any().optional(),
stateCount: Joi.number().optional(),
retryCount: Joi.number().required(),
channelConversation: Joi.object().keys({
botId: Joi.string().required(),
sessionId: Joi.string().required(),
type: Joi.string().required(),
userId: Joi.string().required(),
sessionExpiryDuration: Joi.number().optional(),
channelId: Joi.string().required()
}),
componentResponse: Joi.any(),
executionContext: Joi.string(),
tenantId: Joi.string().required(),
createdOn: Joi.string().required(),
id: Joi.string().required(),
callbackToken: Joi.string().optional()
});
const propertiesSchema = Joi.object().pattern(/(.*)/,
Joi.alternatives().try([Joi.string().allow(''), Joi.number(), Joi.boolean(), Joi.array(), Joi.object()]));
const requestSchema = Joi.object({
botId: Joi.string().required(),
platformVersion: Joi.string().required(),
context: contextSchema.required(),
properties: propertiesSchema,
message: messageSchema.required()
});
return requestSchema.validate(reqBody, {allowUnknown: true});
}
return ComponentInvocation;
};