Here's the script. Hope you enjoy it!   v. 0.2.1 Major Update:  rolltemplate support, and rudimentary attribute field support.   v0.2.3 Minor bugfix:   on --footer   v0.2.3 Minor Bugfix on the bugfix...    v0.3.5 Added Filtering    v0.3.7 Bugfix    v0.3.8 Fix for repeating names that contain other repeating names, and inline rolls    v0.3.9 Added noarchive to sendChat        var universalChatMenu = universalChatMenu || (function () {      'use strict';       const version = '0.3.9',          scriptName = 'Universal Chat Menu',          lastUpdate = 1587394142075,          //defaultTitle = 'Actions',          defaultHeader = (who, actions = 'Actions') => `${who} ${actions} Menu`,          BUTTONSYMBOL = '!',          MENUPLACE = 'CHATMENU',          SEPARATOR = ' ',           checkInstall = () => {              log('-=> ' + scriptName + ' v' + version + ' <=-   [' + (new Date(lastUpdate)) + ']');          },          ch = function (c) {              var entities = {                  '<': 'lt',                  '>': 'gt',                  "'": '#39',                  '@': '#64',                  '{': '#123',                  '|': '#124',                  '}': '#125',                  '[': '#91',                  ']': '#93',                  '"': 'quot',                  '-': 'mdash',                  ' ': 'nbsp'              };               if (_.has(entities, c)) {                  return ('&' + entities[c] + ';');              }              return '';          },           // build attribute name for a repeating section          repname = (section, id, name) => `repeating_${section}_${id}_${name}`,           // get an array of attributes on this character - handy to shorten code elsewhere.          getAttributes = (cid) => {              return findObjs({                  _type: 'attribute',                  _characterid: cid              });          },           // find a list of all the row ids in a repeating section.          getRepeatingIDs = (cid, section, attribute) => {              const repeating = getAttributes(cid)                  .filter((obj) => {                      return obj.get('name').startsWith(`repeating_${section}_`) && obj.get('name').endsWith(attribute);                  })                  .map(obj => obj.get('name').replace(`repeating_${section}_`, '').replace('_' + attribute, '').trim());              return repeating;          },           checkFilter = (check, cid, prefix = '') => { // prefix is for repeating stats, supply 'repeating_${section}_${id}_' as the prefix              //log('=== FILTER ====');              //log(`check: ${check} prefix: ${prefix}`);              let tests = check.toString().split(',');              let rules = [];              let pass = 1; // if tests are passed, return 1, otherwise 0;              tests.forEach(test => { // need to check it has the three results, if only one, use rule.match = 0, and rule.type = '>'                   let s = test.split(/[!<>=]/);                  let rule = {};                  rule.stat = s[0];                  rule.match = s.length > 1 ? s[1] : '';                  rule.type = s.length > 1 ? test.replace(s[0], '').replace(s[1], '') : '#'; // # is used for no filter                  rules.push(rule);                  // rules is an array of objects. don't need a key since will just be looping through it.              });              //log(`rules: ${JSON.stringify(rules)}`);              // now have a set of rules can use to test the filters              // will return true or false              for (let rule of rules) {                  let value = getAttrByName(cid, `${prefix}${rule.stat}`) || ''; // if cell is empty, it is set to null                                    switch (rule.type) {                      case '#':   //no filter supplied, just include if attribute exists                          pass = (0 === +value || (typeof value === 'string' && value.trim() === '')) ? false : true;                          break;                      case '=':  // if rule.match exists in the target cell; if its a number treat as perfect match. Problem: null ||0 turns empty cells into 0.                          if(isNaN(value)) {                              pass = value.toString().toLowerCase().includes(rule.match.toString().toLowerCase());                          } else {                              pass = (+value || 0) === (+rule.match || 0);                          }                          break;                      case '>':                          pass = (+value || 0) >= (+rule.match || Infinity);                          break;                      case '<':                          pass = (parseInt(value, 10) || 0) < (parseInt(rule.match, 10) || 0);                          break;                      case '!':                          if(isNaN(value)) {                              pass = !value.toString().toLowerCase().includes(rule.match.toString().toLowerCase());                          } else {                              pass = (+value || 0) !== (+rule.match || 0);                          }                          break;                  }                  if (!pass) break;              }              // at this point, pass will be 0 or 1, true or false;              return pass;          },           buildButtons = (cid, who, args) => {              let parameters = {};              let title = '';              parameters[title] = [];               let settings_allowed = ['title', 'footer', 'separator'];              for (let arg of args) {                  let settings = arg.split(':');                  let setting = settings[0].toLowerCase();                  let setting_details = settings.length > 0 ? settings[1] : '';                  if (settings_allowed.includes(setting)) {                      // this is a config setting; only one supported right now is title                      // if new title, create an empty array in parameters to hold the following attributes.                      switch (setting) {                          case 'title':                              title = setting_details; // dont need defaulttitle here any more //|| defaultTitle;                              if (!parameters.hasOwnProperty(title)) parameters[title] = [];                              break;                          case 'footer':                          case 'separator':                              parameters[setting] = setting_details;                              break;                       }                  } else if (arg.toLowerCase().startsWith('repeating_')) {                      // this is a repeating section, need to split on "|"                      const repeatingAttribute = arg.split('|');                       if (repeatingAttribute.length < 3) {                          // not valid, skip it                          parameters['error'] = 'Error in Repeating Attributue: ' + arg;                          break;                      }                      const section = repeatingAttribute[0].replace('repeating_', '').trim(); //repeating section name                      const display = repeatingAttribute[1].trim() || 'N/A'; //display name                      let button = repeatingAttribute[2].trim() || 'N/A';                      let buttonAttr = getButtonType(button);                      if (buttonAttr) button = button.split(BUTTONSYMBOL)[0].trim();                       if (display === 'N/A' || button === 'N/A') {                          // this will likely never trigger, it just wont print that section.                          parameters['error'] = 'Error in Repeating Attributue: ' + arg;                          break;                      }                      let repeating = getRepeatingIDs(cid, section, display);                      let checkAttribute = repeatingAttribute.length > 3 ? repeatingAttribute.slice(3) : [];                                             // build chat menu buttons                      repeating.forEach(id => {                          let showButton = 1;                          if (checkAttribute.length > 0) {                              showButton = checkFilter(checkAttribute,cid, `repeating_${section}_${id}_`);                          }                          if (showButton > 0) {                              parameters[title].push(                                  getButtonCode(getAttrByName(cid, repname(section, id, display)), who, repname(section, id, button), buttonAttr));                          }                      });                   } else {                      // this is a non-repeating set of attributes, split on | and then ,                      let singleAttributes = arg.split('|');                      singleAttributes.forEach(attr => {                          let attr_array = attr.split(',');                          let label = attr_array[0];                          let button = attr_array[1] || label;                                                    let check = attr_array.length > 2 ? attr_array.slice(2) : [];                           let buttonAttr = getButtonType(button);                          if (buttonAttr) button = button.split(BUTTONSYMBOL)[0].trim();                           // if check doesnt exist, or if it does exist but has a value of zero, print it. Otherwise don';'t                          let pass = 1;                          if(check.length > 0) {                              //log(`${label}: ${check}`);                              pass = checkFilter(check,cid,'');                          }                          if (pass) {                              parameters[title].push(                                  getButtonCode(label, who, button, buttonAttr));                           }                      });                  }              }              return parameters;          },          getButtonType = (button) => button.split(BUTTONSYMBOL).length > 1,           getButtonCode = (label, who, button, attribute = false) => {              let code = `[${label}](`;              if (attribute) {                  code += `!&#13;/w gm &amp;${ch('{')}template:default${ch('}')}${ch('{')}${ch('{')}name=${ch('@')}${ch('{')}selected|character_name${ch('}')} ${label}${ch('}')}${ch('}')}${ch('{')}${ch('{')}=${ch('@')}${ch('{')}selected${ch('|')}${button}${ch('}')}${ch('}')}${ch('}')})`;              } else {                  code += `~${who}|${button})`;              }              return code;          },           extractFrom = (buttons, field, fallback) => {              let found = fallback;              if (buttons.hasOwnProperty(field)) {                  found = buttons[field];                  delete buttons[field];              }              return found;          },           processInlinerolls = (msg) => {              if (_.has(msg, 'inlinerolls')) {                  return _.chain(msg.inlinerolls)                      .reduce(function(previous, current, index) {                          previous['$[[' + index + ']]'] = current.results.total || 0;                          return previous;                      },{})                      .reduce(function(previous, current, index) {                          return previous.replace(index, current);                      }, msg.content)                      .value();              } else {                  return msg.content;              }          },           handleInput = (msg_orig) => {              // copy the body of your on(chat:message) event to this function               if ('api' === msg_orig.type && msg_orig.content.toLowerCase().startsWith('!chatmenu ')) {                  let msg = _.clone(msg_orig);                  let args = processInlinerolls(msg).split(/\s+--/);                   // get character and heading                  let parameters = args[0].split(/\s+/);                  const cid = parameters[1] || ''; //character id                  if (cid === '') {                      sendChat(scriptName, 'No recognised character.');                      return;                  }                  const who = getAttrByName(cid, 'character_name');                  if (!who) {                      sendChat(scriptName, 'No recognised character.');                      return;                  }                  const sender = 'character|' + cid;                  const caller = (getObj('player', msg.playerid) || {                      get: () => 'API'                  }).get('_displayname');                  let header = parameters.slice(2).join(' ') || defaultHeader(who);                  // if this starts with {template: it is a rolltemplate                   // and if it contains CHATMENU, the menu buttons are placed there. Otherwise they are placed at the end.                    // get buttons                      let combineSections = false; // if false, defaultTemplate sections; if true, combine all sections into one                  args.shift();                  let buttons = buildButtons(cid, who, args);                  // buttons will be an object, each item key is a section title, and each item value is an array of buttons in that section                  // build chat menu buttons                   if (buttons.hasOwnProperty('error')) {                      sendChat(scriptName, buttons['error'].toString());                      return;                  }                   let headerPrint = `&{template:default}{{name=${header}}}`;                  if (header.includes('template:')) {                      headerPrint = `&${header}`;                  }                  headerPrint = headerPrint.replace(/(\[)/g, '[[').replace(/(\])/g, ']]'); // make sure no inline rolls present                    let footer = extractFrom(buttons, 'footer', '');                  let separator = extractFrom(buttons, 'separator', SEPARATOR);                   let output = '';                  let sections = Object.keys(buttons);                  if (headerPrint.includes(MENUPLACE)) combineSections = true;                  for (let section of sections) {                      if (buttons[section].length > 0) {                          output += (combineSections ? `**${section.toUpperCase()}**\n` : `{{**${section}**=`);                          output += buttons[section].join(separator);                          output += (combineSections ? `\n` : `}}`);                      }                  }                  let print = combineSections ? headerPrint.replace(MENUPLACE, output) : headerPrint + output;                  print += footer ? `\n/w ${caller} ${footer}` : '';                  sendChat(sender, `/w ${caller} ${print}`,null,{noarchive:true});              }          },           registerEventHandlers = () => {              on('chat:message', handleInput);          };       return {          CheckInstall: checkInstall,          RegisterEventHandlers: registerEventHandlers      };   }());    on('ready', () => {      'use strict';      universalChatMenu.CheckInstall();      universalChatMenu.RegisterEventHandlers();  });