silentrob/superscript

View on GitHub
src/bot/processTags.js

Summary

Maintainability
D
1 day
Test Coverage
// TODO: Fix this documentation, options is incorrect
/**
 * Parse the reply for additional tags, this is called once we have a reply candidate filtered out.
 *
 * @param {Object} replyObj - The Reply Object
 * @param {string} replyObj.id - This is the 8 digit id mapping back to the ss parsed json
 * @param {array} replyObj.stars - All of the matched values
 * @param {string} replyObj.topic - The Topic name we matched on
 * @param {Object} replyObj.reply - This is the Mongo Reply Gambit
 * @param {string} replyObj.trigger - The input string of the gambit the user matched with their message
 * @param {string} replyObj.trigger_id - The trigger id (8 digit)
 * @param {string} replyObj.trigger_id2 - The trigger id (mongo id)
 *
 * @param {Object} options
 * @param {Object} options.user - The user object
 * @param {Object} options.system - Extra cached items that are loaded async during load-time
 * @param {Object} options.message - The original message object
 *
 * @param {array} options.system.plugins - An array of plugins loaded from the plugin folder
 * @param {Object} options.system.scope - All of the data available to `this` inside of the plugin during execution
 * @param {number} options.depth - Counter of how many times this function is called recursively.
 *
 * Replies can have the following:
 * Basic (captured text) subsitution ie: `I like <cap1>`
 * Input (parts of speech) subsitution ie: `I like <noun>`
 * Expanding terms using wordnet ie: `I like ~sport`
 * Alternate terms to choose at random ie: `I like (baseball|hockey)`
 * Custom functions that can be called ie: `I like ^chooseSport()`
 * Redirects to another reply ie: `I like {@sport}`
 */

import _ from 'lodash';
import debuglog from 'debug-levels';
import peg from 'pegjs';
import fs from 'fs';
import safeEval from 'safe-eval';

import Utils from './utils';
import regexes from './regexes';
import wordnet from './reply/wordnet';

import inlineRedirect from './reply/inlineRedirect';
import topicRedirect from './reply/topicRedirect';
import respond from './reply/respond';
import customFunction from './reply/customFunction';

const debug = debuglog('SS:ProcessTags');

const grammar = fs.readFileSync(`${__dirname}/reply/reply-grammar.pegjs`, 'utf-8');
// Change trace to true to debug peg
const parser = peg.generate(grammar, { trace: false });

const preprocessGrammar = fs.readFileSync(`${__dirname}/reply/preprocess-grammar.pegjs`, 'utf-8');
// Change trace to true to debug peg
const preprocessParser = peg.generate(preprocessGrammar, { trace: false });

/* topicRedirect
/ respond
/ redirect
/ customFunction
/ newTopic
/ capture
/ previousCapture
/ clearConversation
/ continueSearching
/ endSearching
/ previousInput
/ previousReply
/ wordnetLookup
/ alternates
/ delay
/ setState
/ string*/

const processCapture = function processCapture(tag, replyObj, options) {
  const starID = (tag.starID || 1) - 1;
  debug.verbose(`Processing capture: <cap${starID + 1}>`);
  const replacedCapture = (starID < replyObj.stars.length) ? replyObj.stars[starID] : '';
  debug.verbose(`Replacing <cap${starID + 1}> with "${replacedCapture}"`);
  return replacedCapture;
};

const processPreviousCapture = function processPreviousCapture(tag, replyObj, options) {
  // This is to address GH-207, pulling the stars out of the history and
  // feeding them forward into new replies. It allows us to save a tiny bit of
  // context though a conversation cycle.
  // TODO: handle captures within captures, but only 1 level deep
  const starID = (tag.starID || 1) - 1;
  const conversationID = (tag.conversationID || 1) - 1;
  debug.verbose(`Processing previous capture: <p${conversationID + 1}cap${starID + 1}>`);
  let replacedCapture = '';

  if (options.user.history[conversationID].stars && options.user.history[conversationID].stars[starID]) {
    replacedCapture = options.user.history[conversationID].stars[starID];
    debug.verbose(`Replacing <p${conversationID + 1}cap${starID + 1}> with "${replacedCapture}"`);
  } else {
    debug.verbose('Attempted to use previous capture data, but none was found in user history.');
  }
  return replacedCapture;
};

const processPreviousInput = function processPreviousInput(tag, replyObj, options) {
  if (tag.inputID === null) {
    debug.verbose('Processing previous input <input>');
    // This means <input> instead of <input1>, <input2> etc. so give the current input back
    const replacedInput = options.message.clean;
    return replacedInput;
  }

  const inputID = (tag.inputID || 1) - 1;
  debug.verbose(`Processing previous input <input${inputID + 1}>`);
  let replacedInput = '';
  if (options.user.history.length === 0) {
    // Nothing yet in the history
    replacedInput = '';
  } else {
    replacedInput = options.user.history[inputID].input.original;
  }
  debug.verbose(`Replacing <input${inputID + 1}> with "${replacedInput}"`);
  return replacedInput;
};

const processPreviousReply = function processPreviousReply(tag, replyObj, options) {
  const replyID = (tag.replyID || 1) - 1;
  debug.verbose(`Processing previous reply <reply${replyID + 1}>`);
  let replacedReply = '';
  if (options.user.history === 0) {
    // Nothing yet in the history
    replacedReply = '';
  } else {
    replacedReply = options.user.history[replyID].reply;
  }
  debug.verbose(`Replacing <reply{replyID + 1}> with "${replacedReply}"`);
  return replacedReply;
};

const processWordnetLookup = async function processWordnetLookup(tag, replyObj, options) {
  debug.verbose(`Processing wordnet lookup for word: ~${tag.term}`);
  let words = await wordnet.lookup(tag.term, '~');
  words = words.map(item => item.replace(/_/g, ' '));
  debug.verbose(`Terms found in wordnet: ${words}`);

  const replacedWordnet = Utils.pickItem(words);
  debug.verbose(`Wordnet replaced term: ${replacedWordnet}`);
  return replacedWordnet;
};

// Replacements are captures or wordnet lookups
const processReplacement = async function processReplacement(tag, replyObj, options) {
  switch (tag.type) {
    case 'capture': {
      return processCapture(tag, replyObj, options);
    }
    case 'previousCapture': {
      return processPreviousCapture(tag, replyObj, options);
    }
    case 'previousInput': {
      return processPreviousInput(tag, replyObj, options);
    }
    case 'previousReply': {
      return processPreviousReply(tag, replyObj, options);
    }
    case 'wordnetLookup': {
      return processWordnetLookup(tag, replyObj, options);
    }
    default: {
      throw new Error(`Replacement tag type does not exist: ${tag.type}`);
    }
  }
};

const preprocess = async function preprocess(reply, replyObj, options) {
  let captureTags = preprocessParser.parse(reply);
  captureTags = _.flattenDeep(captureTags);
  const cleanTags = await Promise.all(captureTags.map(async (tag) => {
    // Don't do anything to non-captures/wordnet terms
    if (typeof tag === 'string') {
      return tag;
    }
    // It's a capture or wordnet lookup e.g. <cap2> or ~like, so replace it with
    // the captured star in replyObj.stars or a random selection of wordnet term
    const replacement = await processReplacement(tag, replyObj, options);
    return `"${replacement}"`;
  }));
  return cleanTags.join('');
};

const postAugment = function postAugment(replyObject, augmentedReplyObject) {
  replyObject.continueMatching = augmentedReplyObject.continueMatching;
  replyObject.clearConversation = replyObject.clearConversation || augmentedReplyObject.clearConversation;
  replyObject.topic = augmentedReplyObject.topicName;
  replyObject.props = _.merge(replyObject.props, augmentedReplyObject.props);

  // Keep track of all the ids of all the triggers we go through via redirects
  if (augmentedReplyObject.replyIds) {
    augmentedReplyObject.replyIds.forEach((replyId) => {
      replyObject.replyIds.push(replyId);
    });
  }

  if (augmentedReplyObject.subReplies) {
    if (replyObject.subReplies) {
      replyObject.subReplies = replyObject.subReplies.concat(augmentedReplyObject.subReplies);
    } else {
      replyObject.subReplies = augmentedReplyObject.subReplies;
    }
  }

  replyObject.debug = augmentedReplyObject.debug;
  return augmentedReplyObject.string;
};

const processTopicRedirect = async function processTopicRedirect(tag, replyObj, options) {
  let cleanedArgs = null;
  try {
    cleanedArgs = safeEval(tag.functionArgs);
  } catch (err) {
    throw new Error(`Error processing topicRedirect args: ${err}`);
  }

  const topicName = cleanedArgs[0];
  const topicTrigger = cleanedArgs[1];

  debug.verbose(`Processing topic redirect ^topicRedirect(${topicName},${topicTrigger})`);
  options.depth += 1;
  const augmentedReplyObject = await topicRedirect(topicName, topicTrigger, options);
  return postAugment(replyObj, augmentedReplyObject);
};

const processRespond = async function processRespond(tag, replyObj, options) {
  let cleanedArgs = null;
  try {
    cleanedArgs = safeEval(tag.functionArgs);
  } catch (err) {
    throw new Error(`Error processing respond args: ${err}`);
  }

  const topicName = cleanedArgs[0];

  debug.verbose(`Processing respond: ^respond(${topicName})`);
  options.depth += 1;
  const augmentedReplyObject = await respond(topicName, options);
  return postAugment(replyObj, augmentedReplyObject);
};

const processRedirect = async function processRedirect(tag, replyObj, options) {
  debug.verbose(`Processing inline redirect: {@${tag.trigger}}`);
  options.depth += 1;
  const augmentedReplyObject = await inlineRedirect(tag.trigger, options);
  return postAugment(replyObj, augmentedReplyObject);
};

const processCustomFunction = async function processCustomFunction(tag, replyObj, options) {
  if (tag.functionArgs === null) {
    debug.verbose(`Processing custom function: ^${tag.functionName}()`);
    return customFunction(tag.functionName, [], replyObj, options);
  }

  let cleanArgs = null;
  try {
    cleanArgs = safeEval(tag.functionArgs);
  } catch (e) {
    throw new Error(`Error processing custom function arguments: ${e}`);
  }

  const response = await customFunction(tag.functionName, cleanArgs, replyObj, options);
  // The custom function might return something with more tags, so do it all again
  let preprocessed;
  try {
    preprocessed = await preprocess(response, replyObj, options);
  } catch (err) {
    throw new Error(`There was an error preprocessing reply tags: ${err}`);
  }

  const replyTags = parser.parse(preprocessed);

  try {
    const processedReplyParts = await Promise.all(replyTags.map(async tag => (
      processTag(tag, replyObj, options)
    )));
    return processedReplyParts.join('').trim();
  } catch (err) {
    throw new Error(`There was an error processing reply tags: ${err}`);
  }
};

const processNewTopic = async function processNewTopic(tag, replyObj, options) {
  debug.verbose(`Processing new topic: ${tag.topicName}`);
  const newTopic = tag.topicName;
  await options.user.setTopic(newTopic);
  return '';
};

const processClearConversation = function processClearConversation(tag, replyObj, options) {
  debug.verbose('Processing clear conversation: setting clear conversation to true');
  replyObj.clearConversation = true;
  return '';
};

const processContinueSearching = function processContinueSearching(tag, replyObj, options) {
  debug.verbose('Processing continue searching: setting continueMatching to true');
  replyObj.continueMatching = true;
  return '';
};

const processEndSearching = function processEndSearching(tag, replyObj, options) {
  debug.verbose('Processing end searching: setting continueMatching to false');
  replyObj.continueMatching = false;
  return '';
};

const processAlternates = function processAlternates(tag, replyObj, options) {
  debug.verbose(`Processing alternates: ${tag.alternates}`);
  const alternates = tag.alternates;
  const random = Utils.getRandomInt(0, alternates.length - 1);
  const result = alternates[random];
  return result;
};

const processDelay = function processDelay(tag, replyObj, options) {
  return `{delay=${tag.delayLength}}`;
};

const processSetState = function processSetState(tag, replyObj, options) {
  debug.verbose(`Processing setState: ${JSON.stringify(tag.stateToSet)}`);
  const stateToSet = tag.stateToSet;
  const newState = {};
  stateToSet.forEach((keyValuePair) => {
    const key = keyValuePair.key;
    let value = keyValuePair.value;

    // Value is a string
    value = value.replace(/["']/g, '');

    // Value is an integer
    if (/^[\d]+$/.test(value)) {
      value = +value;
    }

    // Value is a boolean
    if (value === 'true') {
      value = true;
    } else if (value === 'false') {
      value = false;
    }

    newState[key] = value;
  });
  debug.verbose(`New state: ${JSON.stringify(newState)}`);
  options.user.conversationState = _.merge(options.user.conversationState, newState);
  options.user.markModified('conversationState');
  return '';
};

const processTag = async function processTag(tag, replyObj, options) {
  if (typeof tag === 'string') {
    return tag;
  }

  const tagType = tag.type;
  switch (tagType) {
    case 'capture':
    case 'previousCapture':
    case 'previousInput':
    case 'previousReply':
    case 'wordnetLookup': {
      return processReplacement(tag, replyObj, options);
    }
    case 'topicRedirect': {
      return processTopicRedirect(tag, replyObj, options);
    }
    case 'respond': {
      return processRespond(tag, replyObj, options);
    }
    case 'customFunction': {
      return processCustomFunction(tag, replyObj, options);
    }
    case 'newTopic': {
      return processNewTopic(tag, replyObj, options);
    }
    case 'clearConversation': {
      return processClearConversation(tag, replyObj, options);
    }
    case 'continueSearching': {
      return processContinueSearching(tag, replyObj, options);
    }
    case 'endSearching': {
      return processEndSearching(tag, replyObj, options);
    }
    case 'redirect': {
      return processRedirect(tag, replyObj, options);
    }
    case 'alternates': {
      return processAlternates(tag, replyObj, options);
    }
    case 'delay': {
      return processDelay(tag, replyObj, options);
    }
    case 'setState': {
      return processSetState(tag, replyObj, options);
    }
    default: {
      throw new Error(`No such tag type: ${tagType}`);
    }
  }
};


const processReplyTags = async function processReplyTags(replyObj, options) {
  debug.verbose('Depth: ', options.depth);

  let replyString = replyObj.reply.reply;
  debug.info(`Reply before processing reply tags: "${replyString}"`);

  options.topic = replyObj.topic;
  replyObj.replyIds = [replyObj.reply._id];

  // Deals with captures and wordnet lookups within functions as a preprocessing step
  // e.g. ^myFunction(<cap1>, ~hey, "otherThing")
  let preprocessed;
  try {
    preprocessed = await preprocess(replyString, replyObj, options);
  } catch (err) {
    console.error(`There was an error preprocessing reply tags: ${err}`);
    return null;
  }

  const replyTags = parser.parse(preprocessed);

  let processedReplyParts;
  try {
    processedReplyParts = await Promise.all(replyTags.map(async tag => (
      processTag(tag, replyObj, options)
    )));
  } catch (err) {
    console.error(`There was an error processing reply tags: ${err}`);
    if (replyTags[0].type === 'customFunction'){
      console.error(`There is a syntax error in ${replyTags[0].functionName}`);
    }
    return null;
  }

  replyString = processedReplyParts.join('').trim();

  const spaceRegex = /\\s/g;
  replyObj.reply.reply = replyString.replace(spaceRegex, ' ');

  debug.verbose('Final reply object from processTags: ', replyObj);

  if (_.isEmpty(options.user.pendingTopic)) {
    await options.user.setTopic(replyObj.topic);
  }

  return replyObj;
};

const processThreadTags = function processThreadTags(string) {
  const threads = [];
  const strings = [];
  string.split('\n').forEach((line) => {
    const match = line.match(regexes.delay);
    if (match) {
      threads.push({ delay: match[1], string: line.replace(match[0], '').trim() });
    } else {
      strings.push(line);
    }
  });
  return [strings.join('\n'), threads];
};

export default {
  preprocess,
  processThreadTags,
  processReplyTags,
};