Ok, here is the Interlocutor script to test the receipt of messages: /* ========================================================= Name : Interlocutor GitHub : Roll20 Contact : timmaugh Version : 0.0.1 Last Update : 2/21/2021 ========================================================= */ var API_Meta = API_Meta || {}; API_Meta.Interlocutor = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; { try { throw new Error(''); } catch (e) { API_Meta.Interlocutor.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); } } const Interlocutor = (() => { const apiproject = 'Interlocutor'; const version = '0.0.1'; const schemaVersion = 0.1; API_Meta[apiproject].version = version; const vd = new Date(1613942668454); const versionInfo = () => { log(`\u0166\u0166 ${apiproject} v${API_Meta[apiproject].version}, ${vd.getFullYear()}/${vd.getMonth() + 1}/${vd.getDate()} \u0166\u0166 -- offset ${API_Meta[apiproject].offset}`); if (!state.hasOwnProperty(apiproject) || state[apiproject].version !== schemaVersion) { log(` > Updating ${apiproject} Schema to v${schemaVersion} <`); switch (state[apiproject] && state[apiproject].version) { case 0.1: state[apiproject].active = true; /* break; // intentional dropthrough */ /* falls through */ case 'UpdateSchemaVersion': state[apiproject].version = schemaVersion; break; default: state[apiproject] = { version: schemaVersion, active: true }; break; } } }; const logsig = () => { // initialize shared namespace for all signed projects, if needed state.torii = state.torii || {}; // initialize siglogged check, if needed state.torii.siglogged = state.torii.siglogged || false; state.torii.sigtime = state.torii.sigtime || Date.now() - 3001; if (!state.torii.siglogged || Date.now() - state.torii.sigtime > 3000) { const logsig = '\n' + ' ‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗‗ ' + '\n' + ' ∖_______________________________________∕ ' + '\n' + ' ∖___________________________________∕ ' + '\n' + ' ___┃ ┃_______________┃ ┃___ ' + '\n' + ' ┃___ _______________ ___┃ ' + '\n' + ' ┃ ┃ ┃ ┃ ' + '\n' + ' ┃ ┃ ┃ ┃ ' + '\n' + ' ┃ ┃ ┃ ┃ ' + '\n' + ' ┃ ┃ ┃ ┃ ' + '\n' + ' ┃ ┃ ┃ ┃ ' + '\n' + '______________┃ ┃_______________┃ ┃_______________' + '\n' + ' ⎞⎞⎛⎛ ⎞⎞⎛⎛ ' + '\n'; log(`${logsig}`); state.torii.siglogged = true; state.torii.sigtime = Date.now(); } return; }; // ================================================== // MESSAGING / CHAT REPORTING // ================================================== const HE = (() => { const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g, '\\$1'); const e = (s) => `&${s};`; const entities = { '<': e('lt'), '>': e('gt'), "'": e('#39'), '@': e('#64'), '{': e('#123'), '|': e('#124'), '}': e('#125'), '[': e('#91'), ']': e('#93'), '"': e('quot'), '*': e('#42') }; const re = new RegExp(`(${Object.keys(entities).map(esRE).join('|')})`, 'g'); return (s) => s.replace(re, (c) => (entities[c] || c)); })(); const rowbg = ["#ffffff", "#dedede"]; const headerbg = { normal: rowbg[1], critical: "##F46065" }; const msgtable = '<div style="width:100%;"><div style="border-radius:10px;border:2px solid #000000;background-color:__bg__; margin-right:16px; overflow:hidden;"><table style="width:100%; margin: 0 auto; border-collapse:collapse;font-size:12px;">__TABLE-ROWS__</table></div></div>'; const msg1header = '<tr style="border-bottom:1px solid #000000;font-weight:bold;text-align:center; background-color:__bg__; line-height: 22px;"><td colspan = "__colspan__">__cell1__</td></tr>'; const msg2header = '<tr style="border-bottom:1px solid #000000;font-weight:bold;text-align:center; background-color:__bg__; line-height: 22px;"><td>__cell1__</td><td style="border-left:1px solid #000000;">__cell2__</td></tr>'; const msg3header = '<tr style="border-bottom:1px solid #000000;font-weight:bold;text-align:center; background-color:__bg__; line-height: 22px;"><td>__cell1__</td><td style="border-left:1px solid #000000;">__cell2__</td><td style="border-left:1px solid #000000;">__cell3__</td></tr>'; const msg1row = '<tr style="background-color:__bg__;"><td style="padding:4px;"><div style="__row-css__">__cell1__</div></td></tr>'; const msg2row = '<tr style="background-color:__bg__;font-weight:bold;"><td style="padding:1px 4px;">__cell1__</td><td style="border-left:1px solid #000000;text-align:center;padding:1px 4px;font-weight:normal;">__cell2__</td></tr>'; const msg3row = '<tr style="background-color:__bg__;font-weight:bold;"><td style="padding:1px 4px;">__cell1__</td><td style="border-left:1px solid #000000;text-align:center;padding:1px 4px;font-weight:normal;">__cell2__</td><td style="border-left:1px solid #000000;text-align:center;padding:1px 4px;font-weight:normal;">__cell3__</td></tr>'; const msgbox = ({ c: c = "chat message", t: t = "title", btn: b = "buttons", send: send = false, sendas: sas = "API", wto: wto = "", type: type = "normal" }) => { let tbl = msgtable.replace("__bg__", rowbg[0]); let hdr = msg1header.replace("__bg__", headerbg[type]).replace("__cell1__", t); let row = msg1row.replace("__bg__", rowbg[0]).replace("__cell1__", c); let btn = b !== "buttons" ? msg1row.replace("__bg__", rowbg[0]).replace("__cell1__", b).replace("__row-css__", "text-align:right;margin:4px 4px 8px;") : ""; let msg = tbl.replace("__TABLE-ROWS__", hdr + row + btn); if (wto) msg = `/w "${wto}" ${msg}`; if (["t", "true", "y", "yes", true].includes(send)) { sendChatOrig(sas, msg); } else { return msg; } }; const replacer = (key, value) => { // Filtering out properties if (key === 'signature') { return undefined; } return value; }; const syntaxHighlight = (str, replacer = undefined) => { const css = { stringstyle: 'mediumblue;', numberstyle: 'magenta;', booleanstyle: 'darkorange;', nullstyle: 'darkred;', keystyle: 'darkgreen;' }; if (typeof str !== 'string') { str = JSON.stringify(str, replacer, ' '); } str = str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, function (match) { let cls = 'numberstyle'; if (/^"/.test(match)) { if (/:$/.test(match)) { cls = 'keystyle'; } else { cls = 'stringstyle'; } } else if (/true|false/.test(match)) { cls = 'booleanstyle'; } else if (/null/.test(match)) { cls = 'nullstyle'; } return '<span style=" color: ' + css[cls] + '">' + HE(match.replace(/^"(.*)"(:?)$/g, ((m, g1, g2) => `${g1}${g2}`)).replace(/\\(.)/g, `$1`)) + '</span>'; }); }; const showObjInfo = (o, t = 'PARSED OBJECT', replacer = undefined) => { msgbox({ t: t, c: `<div><pre style="background: transparent; border: none;white-space: pre-wrap;font-family: Inconsolata, Consolas, monospace;">${syntaxHighlight(o || '', replacer).replace(/\n/g, '<br>')}</pre></div>`, send: true }); return; }; let preservedMsgObj = {}; const generateUUID = (() => { let a = 0; let b = []; return () => { let c = (new Date()).getTime() + 0; let f = 7; let e = new Array(8); let d = c === a; a = c; for (; 0 <= f; f--) { e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64); c = Math.floor(c / 64); } c = e.join(""); if (d) { for (f = 11; 0 <= f && 63 === b[f]; f--) { b[f] = 0; } b[f]++; } else { for (f = 0; 12 > f; f++) { b[f] = Math.floor(64 * Math.random()); } } for (f = 0; 12 > f; f++) { c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]); } return c; }; })(); const conditionalPluck = (array, key, cobj = {}) => { // test array of objects to return a given property of each object if all conditions are met // cobj properties are functions testing that property (k) in the evaluated object (o) // to test if testedproperty equals a given value: { testedProperty: (k,o) => { return o[k] === 'given value'; } } // to test if testedproperty exists: { testedProperty: (k,o) => { return o.hasOwnProperty(k); } } return array.map(o => { let b = true; if (cobj) { Object.keys(cobj).forEach(k => { if (b && !cobj[k](k, o)) { b = false; } }); } if (b) return o[key]; }).filter(e => e); }; const controlBoard = msg => { // turn on/off state[apiproject].active // also, to report out the msg array // also, to clear the msg array let c = msg.content.split(/\s+/).slice(1).join(' '); switch (c.toLowerCase()) { case 'on': state[apiproject].active = true; assignChat(); return; case 'off': state[apiproject].active = false; assignChat(); return; case 'clear': case 'reset': preservedMsgObj = {}; msgbox({ c: `All tracked messages deleted.`, t: `INTERLOCUTOR`, send: true }); return; default: break; } if (/^(report|show)/.test(c)) { let show = /\s+/.test(c) ? c.split(/\s+/).slice(1).join(' ') : 'base'; let repObj; switch (show.toLowerCase()) { case 'content': repObj = Object.keys(preservedMsgObj).map(m => { return { MESSAGE: `${m}`, DISPATCH: preservedMsgObj[m].dispatch.content, RECEIVED: !preservedMsgObj[m].dispatch.content.startsWith('!') ? 'None required' : preservedMsgObj[m].received.content || '' }; }); break; case 'base': default: repObj = Object.keys(preservedMsgObj).map(m => `${m}: ${!preservedMsgObj[m].dispatch.content.startsWith('!') ? 'None required' : preservedMsgObj[m].received.hasOwnProperty('content') ? 'RECEIVED' : ''}`); break; } showObjInfo(repObj, 'INTERLOCUTOR REPORT'); } }; const sendChatOrig = sendChat; const sendChatI = (from, cmd, ...args) => { let apitrigger = `${apiproject}${generateUUID()}`; // apitrigger used to track this message preservedMsgObj[apitrigger] = { dispatch: { from: from, content: cmd }, received: {} }; sendChatOrig(from, /^!/.test(cmd) ? `!${apitrigger}${cmd}` : cmd, ...args); }; const assignChat = () => { sendChat = state.Interlocutor.active ? Interlocutor.sendChatI : Interlocutor.sendChatOrig; showObjInfo(state[apiproject], `INTERLOCUTOR STATUS`); }; on('chat:message', (msg) => { if (msg.type !== 'api') return; if (/^!interlocutor\s+.+/.test(msg.content)) { // interlocutor config statement controlBoard(msg); return; } if (state[apiproject].active !== true) return; const trigrx = new RegExp(`^!(${Object.keys(preservedMsgObj).join('|')})`); let apitrigger; // the apitrigger used by the message if (Object.keys(preservedMsgObj).length && trigrx.test(msg.content)) { // message has apitrigger apitrigger = trigrx.exec(msg.content)[1]; msg.content = `${msg.content.replace(trigrx, '').replace(/^\s*/, '')}`; preservedMsgObj[apitrigger].received = _.clone(msg); } else { // interlocutor is ON, this is not an interlocutor config message, and message has no apitrigger... this is probably a user-generated message apitrigger = `UserGenerated${generateUUID()}`; // apitrigger used to track this message preservedMsgObj[apitrigger] = { dispatch: { from: msg.who, content: msg.content }, received: _.clone(msg) }; } }); on('ready', () => { versionInfo(); logsig(); assignChat(); }) return { sendChatI: sendChatI, sendChatOrig: sendChatOrig }; })(); //sendChat = state.Interlocutor.active || false ? Interlocutor.sendChatI : Interlocutor.sendChatOrig; { try { throw new Error(''); } catch (e) { API_Meta.Interlocutor.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Interlocutor.offset); } } When you enable that and reboot your sandbox, you'll get a report as to whether the Interlocutor is active (on). It should install as active. To turn it on/off, use the handle: !interlocutor on !interlocutor off When it is on, it will be collecting upstream and downstream messages, and it will keep collecting them until you turn it off or until you clear it. To clear it, use either 'clear' or 'reset' with the handle: !interlocutor clear Then, to see a report of what messages went up and down, use either 'report' or 'show'. !interlocutor show There are 2 reports available. The base/default report can be accessed as above (with nothing) or by explicitly adding 'base': !interlocutor show base Otherwise, there is a report that shows the message content of each message. Trigger that with 'content': !interlocutor show content Understanding the Report The base report will show you messages sent that were/were not received back from Roll20. For the above, you can see that the original message shows as user-generated (anything from the chat, macro, or ability), and that it was received. The next 3 lines were api-generated sendChats. The first two were received. The last was not intended to be an API call (it just output text to the chat interface), so we wouldn't expect an API message to be received again... that's why that one says "None Required." Immediately after that, I ran the !interlocutor show content to see the content report for the same batch of messages. This is that output: That is helpful if you have a long series of messages and you wonder which were properly sent vs those that were dropped. Remember to clear the message between tracking runs, and to turn it off when you're done with it (or even disable the script). If it is left "on" and you don't realize you're collecting messages, you've basically opted in for a memory leak. :-o