vÍnce said: I can verify that this macro; !chatmenu @{selected|character_id} {template:general} {{color=@{selected|color_option}}} {{name=@{selected|character_name}}} {{subtag=Chat Menu}} {{freetext=CHATMENU}} --title:Weapons --repeating_weapon|weapon_name|weapon_attack_roll works as expected with the 1e sheet and it only posts once (UNLESS there are 2 copies of the universalChatmenus script in the API mods as Jarren mentioned...) Oddly enough, I was not getting 2 copies of the output in chat yesterday when I originally posted my examples for Tim, but when I tested the weapons macro just now, I did get 2 copies. WTH? I checked my loaded API mods and there were 2 copies of universalChatMenu.js in my mods. I'm 99% sure I only copied and saved one version of GiGs mod yesterday and my posted image from yesterday; shows I was only getting 1 output per macro. Somehow the script ended up being saved twice in my mods list. No idea how that happened. I deleted one of them and script works as expected. Double check your mods Tim. There might be a doppelganger. I don't know what a doppleganger is, but this is just not working. I've copied the code from GiG's post that comes from <a href="https://app.roll20.net/forum/post/7474530/script-call-for-testers-universal-chat-menus/?pageforid=7474537#post-7474537" rel="nofollow">https://app.roll20.net/forum/post/7474530/script-call-for-testers-universal-chat-menus/?pageforid=7474537#post-7474537</a> 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(); }); And used yours, for the macro which I've saved as a macro called "Attack". The screen shot below has "Attack" as a token menu item and in the token bar menu at the bottom. It absolutely gives no results, even though GiGs' script fires as the logs I used earlier indicated - the above does not include my logs and is a direct copy from the above link. I deleted the existing script I created with the logs and created a new script. In all cases, it does output " No recognised character." to the chat if there is no character selected. It will not work on any of the characters in this screen shot. I include the character sheet for the cleric in the group and the weapons he has. I'll spend some more time on GiGs' original post and see if I can get it to work. -- Tim