Macro processing library for JanitorAI. Replaces {{macro}} placeholders in text with computed values. Supports dice rolls, random picks, date/time, and utility macros.
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta
// @require https://update.greasyfork.org/scripts/573103/1794114/JanitorAI%20-%20Macro%20Processor%20Library.js
// ==UserScript==
// @name JanitorAI - Macro Processor Library
// @namespace http://tampermonkey.net/
// @license MIT
// @version 1.0.0
// @description Macro processing library for JanitorAI. Replaces {{macro}} placeholders in text with computed values. Supports dice rolls, random picks, date/time, and utility macros.
// @author ZephyrThePink - @ZephyrThePink on Discord
// @match https://janitorai.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ JanitorAI Macro Processor Library ║
* ╠══════════════════════════════════════════════════════════════╣
* ║ ║
* ║ Processes {{macro}} placeholders in any text string. ║
* ║ Designed as a library consumed by the QoL Plugins script ║
* ║ or called directly from the browser console. ║
* ║ ║
* ║ Macros are case-insensitive, whitespace-tolerant, and ║
* ║ support nesting (inner macros resolve first). ║
* ║ ║
* ╠══════════════════════════════════════════════════════════════╣
* ║ ARGUMENT SEPARATORS ║
* ║ ║
* ║ Double colon (preferred): {{roll::2d6+3}} ║
* ║ Space (single arg): {{roll 2d6+3}} ║
* ║ Single colon (legacy): {{roll:2d6+3}} ║
* ║ ║
* ╠══════════════════════════════════════════════════════════════╣
* ║ AVAILABLE MACROS ║
* ║ ║
* ║ ── Randomization ──────────────────────────────────────── ║
* ║ {{random::a::b::c}} Random pick (re-rolls each ║
* ║ time process() is called) ║
* ║ {{pick::a::b::c}} Stable random pick (same ║
* ║ result for same seed+options)║
* ║ {{roll::NdS}} Dice roll (e.g. 1d20, 2d6+3, ║
* ║ 4d8-2). N ≤ 100, S ≤ 1000 ║
* ║ ║
* ║ ── Date & Time ────────────────────────────────────────── ║
* ║ {{time}} Local time (toLocaleTimeString)║
* ║ {{time::UTC+N}} / {{time::UTC-N}} ║
* ║ Time at UTC offset (e.g. ║
* ║ UTC+5, UTC-3, UTC+5:30) ║
* ║ {{date}} Local date (short) ║
* ║ {{weekday}} Day of the week (full name) ║
* ║ {{isotime}} Time as HH:mm ║
* ║ {{isodate}} Date as YYYY-MM-DD ║
* ║ {{datetimeformat::FMT}} Custom format string ║
* ║ Tokens: YYYY YY MMMM MMM MM M dddd ddd DD D ║
* ║ HH H hh h mm m ss s A a ║
* ║ ║
* ║ ── Math ───────────────────────────────────────────────── ║
* ║ {{abs::N}} Absolute value ║
* ║ {{ceil::N}} Round up ║
* ║ {{floor::N}} Round down ║
* ║ {{round::N}} Round to nearest integer ║
* ║ {{min::a::b[::c...]}} Minimum of values ║
* ║ {{max::a::b[::c...]}} Maximum of values ║
* ║ {{calc::expression}} Evaluate a math expression ║
* ║ Supports: + - * / % ** ( ) ║
* ║ e.g. {{calc::2 + 3 * 4}} → 14 ║
* ║ ║
* ║ ── Utility ────────────────────────────────────────────── ║
* ║ {{newline}} Single newline (\n) ║
* ║ {{newline::N}} N newlines (max 100) ║
* ║ {{space}} Single space ║
* ║ {{space::N}} N spaces (max 100) ║
* ║ {{trim}} Strip surrounding whitespace ║
* ║ {{reverse::text}} Reverse a string ║
* ║ {{noop}} Empty string (no output) ║
* ║ {{// comment}} Comment (stripped from output)║
* ║ {{//}}...{{///}} Block comment (stripped) ║
* ║ {{length::text}} Character count of text ║
* ║ {{lowercase::text}} Convert text to lowercase ║
* ║ {{uppercase::text}} Convert text to UPPERCASE ║
* ║ {{capitalize::text}} Capitalize First Letter ║
* ║ {{replace::find::replace::text}} ║
* ║ Replace first occurrence ║
* ║ {{replaceall::find::replace::text}} ║
* ║ Replace all occurrences ║
* ║ {{substr::start::length::text}} ║
* ║ Substring extraction ║
* ║ {{input}} Current chat input field ║
* ║ ║
* ║ ── Tier 2: Message Macros (require Message Logger) ────── ║
* ║ Auto-registered when JaiMessageLogger library is detected. ║
* ║ ║
* ║ {{lastMessage}} Last message text (any role) ║
* ║ {{lastUserMessage}} Last user message text ║
* ║ {{lastCharMessage}} Last bot message text ║
* ║ {{lastBotMessage}} Alias for lastCharMessage ║
* ║ {{lastMessageId}} Index of the last message ║
* ║ {{currentSwipeId}} Current swipe (1-based) ║
* ║ {{lastSwipeId}} Same as currentSwipeId ║
* ║ {{messageCount}} Total messages in chat ║
* ║ {{botStatus}} Bot status (Complete/ ║
* ║ Streaming/Editing) ║
* ║ {{userStatus}} User status (Complete/Editing)║
* ║ ║
* ╠══════════════════════════════════════════════════════════════╣
* ║ USAGE (from another userscript or console) ║
* ║ ║
* ║ // Basic: ║
* ║ QoLMacros.process("Roll: {{roll::1d20}}") ║
* ║ // → "Roll: 17" ║
* ║ ║
* ║ // With context (seed for {{pick}}): ║
* ║ QoLMacros.process("{{pick::a::b::c}}", { seed: "chat123"})║
* ║ // → "b" (always the same for this seed + options) ║
* ║ ║
* ║ // Nested macros: ║
* ║ QoLMacros.process("{{random::{{roll::1d6}}::fixed}}") ║
* ║ // → inner resolves first, then outer picks between the ║
* ║ // roll result and "fixed" ║
* ║ ║
* ║ // Register a custom macro: ║
* ║ QoLMacros.register("greet", (args, ctx) => "Hello!") ║
* ║ ║
* ║ // List all macros: ║
* ║ QoLMacros.list() ║
* ║ ║
* ║ // Demo all macros: ║
* ║ QoLMacros.demo() ║
* ║ ║
* ╠══════════════════════════════════════════════════════════════╣
* ║ API REFERENCE (window.QoLMacros) ║
* ║ ║
* ║ .ready → true once the library has loaded ║
* ║ .version → library version string ║
* ║ ║
* ║ .process(text, ctx?) → string ║
* ║ Process all macros in text. Optional context object: ║
* ║ { seed: string } — seed for {{pick}} stability ║
* ║ ║
* ║ .register(name, fn) → void ║
* ║ Register a custom macro handler. ║
* ║ fn(args: string[], ctx: object) → string ║
* ║ ║
* ║ .unregister(name) → boolean ║
* ║ Remove a macro handler. Returns true if it existed. ║
* ║ ║
* ║ .has(name) → boolean ║
* ║ Check if a macro handler exists. ║
* ║ ║
* ║ .list() → string[] ║
* ║ List all registered macro names. ║
* ║ ║
* ║ .demo() → void ║
* ║ Run and log a demo of every built-in macro. ║
* ║ ║
* ║ .messageLogger → object|null ║
* ║ Reference to JaiMessageLogger if connected, else null. ║
* ║ ║
* ║ .tier2Connected → boolean ║
* ║ True if Message Logger was detected and Tier 2 macros ║
* ║ are active. ║
* ║ ║
* ╚══════════════════════════════════════════════════════════════╝
*/
(function () {
'use strict';
// ================================================================
// === ARGUMENT PARSING ===
// ================================================================
/**
* Parse a raw macro body into a name and arguments array.
*
* Separator priority:
* 1. Double colon `::` (multi-arg)
* 2. Space (single arg, first space only)
* 3. Single colon `:` (legacy, single arg)
*
* @param {string} raw - The text between {{ and }}
* @returns {{ name: string, args: string[] }}
*/
function parseMacroCall(raw) {
const trimmed = raw.trim();
// Double colon separator → split into [name, arg1, arg2, ...]
if (trimmed.includes('::')) {
const parts = trimmed.split('::');
return {
name: parts[0].trim().toLowerCase(),
args: parts.slice(1).map(a => a.trim())
};
}
// Space separator → name + single arg (everything after first space)
const spaceIdx = trimmed.indexOf(' ');
if (spaceIdx !== -1) {
return {
name: trimmed.substring(0, spaceIdx).trim().toLowerCase(),
args: [trimmed.substring(spaceIdx + 1).trim()]
};
}
// Single colon separator (legacy) → name + single arg
const colonIdx = trimmed.indexOf(':');
if (colonIdx !== -1) {
return {
name: trimmed.substring(0, colonIdx).trim().toLowerCase(),
args: [trimmed.substring(colonIdx + 1).trim()]
};
}
// No arguments
return { name: trimmed.toLowerCase(), args: [] };
}
// ================================================================
// === DICE ROLLER ===
// ================================================================
/**
* Parse and evaluate dice notation.
* Supports: NdS, NdS+M, NdS-M
*
* Constraints: N ≤ 100, S ≤ 1000, |M| ≤ 10000
*
* @param {string} notation - e.g. "2d6+3"
* @returns {number|NaN}
*/
function rollDice(notation) {
const match = notation.trim().match(/^(\d+)\s*d\s*(\d+)\s*([+-]\s*\d+)?$/i);
if (!match) return NaN;
const count = parseInt(match[1], 10);
const sides = parseInt(match[2], 10);
const modifier = match[3] ? parseInt(match[3].replace(/\s/g, ''), 10) : 0;
if (count < 1 || count > 100 || sides < 1 || sides > 1000 || Math.abs(modifier) > 10000) {
return NaN;
}
let total = 0;
for (let i = 0; i < count; i++) {
total += Math.floor(Math.random() * sides) + 1;
}
return total + modifier;
}
// ================================================================
// === STABLE RANDOM (seeded PRNG for {{pick}}) ===
// ================================================================
/**
* Simple deterministic 32-bit hash (djb2 variant).
* @param {string} str
* @returns {number}
*/
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return hash;
}
/**
* Mulberry32 — fast, single-state 32-bit PRNG.
* Returns a float in [0, 1).
* @param {number} seed
* @returns {number}
*/
function mulberry32(seed) {
let t = (seed + 0x6D2B79F5) | 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// ================================================================
// === SAFE MATH EVALUATOR ===
// ================================================================
/**
* Evaluate a simple math expression with +, -, *, /, %, **, and parentheses.
* Only allows digits, operators, whitespace, parens, and decimal points.
* No variables, no function calls — safe from injection.
*
* @param {string} expr
* @returns {number|NaN}
*/
function safeMathEval(expr) {
// Strip whitespace for validation
const cleaned = expr.replace(/\s+/g, '');
// Strict whitelist: digits, decimal points, operators, parens
if (!/^[0-9+\-*/%().]+$/.test(cleaned) && !/^[0-9+\-*/%().\s*]+$/.test(cleaned)) {
// Also allow ** (exponentiation)
if (!/^[0-9+\-*/%(). ]+$/.test(expr.replace(/\*\*/g, '^'))) {
return NaN;
}
}
// Block anything that looks like it could be code
if (/[a-zA-Z_$]/.test(cleaned)) return NaN;
// Block assignment, bitwise, comparison, etc.
if (/[=&|^~!<>?:;,{}[\]\\@#`'"]/.test(cleaned)) return NaN;
// Block empty parens (function call attempt)
if (/\(\)/.test(cleaned)) return NaN;
try {
// Use Function constructor in a limited context (no access to scope)
// The expression has already been validated to contain only safe characters.
const fn = new Function(`"use strict"; return (${expr});`);
const result = fn();
if (typeof result !== 'number' || !isFinite(result)) return NaN;
return result;
} catch {
return NaN;
}
}
// ================================================================
// === DATE / TIME FORMATTING ===
// ================================================================
/**
* Format a Date using a token-based format string.
*
* Tokens (replaced longest-first to avoid partial matches):
* YYYY → 4-digit year YY → 2-digit year
* MMMM → month name (full) MMM → month name (short)
* MM → month (01-12) M → month (1-12)
* dddd → weekday (full) ddd → weekday (short)
* DD → day (01-31) D → day (1-31)
* HH → hours 24h (00-23) H → hours 24h (0-23)
* hh → hours 12h (01-12) h → hours 12h (1-12)
* mm → minutes (00-59) m → minutes (0-59)
* ss → seconds (00-59) s → seconds (0-59)
* A → AM/PM a → am/pm
*
* @param {string} format
* @param {Date} [date]
* @returns {string}
*/
function formatDateTime(format, date) {
date = date || new Date();
const pad = (n) => String(n).padStart(2, '0');
const h24 = date.getHours();
const h12 = h24 % 12 || 12;
const ampm = h24 < 12 ? 'AM' : 'PM';
const daysFull = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const daysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const moFull = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
const moShort = ['Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'];
const tokens = {
'YYYY': date.getFullYear(),
'YY': String(date.getFullYear()).slice(-2),
'MMMM': moFull[date.getMonth()],
'MMM': moShort[date.getMonth()],
'MM': pad(date.getMonth() + 1),
'M': date.getMonth() + 1,
'dddd': daysFull[date.getDay()],
'ddd': daysShort[date.getDay()],
'DD': pad(date.getDate()),
'D': date.getDate(),
'HH': pad(h24),
'H': h24,
'hh': pad(h12),
'h': h12,
'mm': pad(date.getMinutes()),
'm': date.getMinutes(),
'ss': pad(date.getSeconds()),
's': date.getSeconds(),
'A': ampm,
'a': ampm.toLowerCase(),
};
// Replace longest tokens first
let result = format;
const sorted = Object.keys(tokens).sort((a, b) => b.length - a.length);
for (const tok of sorted) {
result = result.split(tok).join(String(tokens[tok]));
}
return result;
}
/**
* Parse a UTC offset string like "UTC+5", "UTC-3:30", "UTC+5:30".
* Returns a Date shifted by the offset, or null on failure.
*
* @param {string} offsetStr
* @returns {Date|null}
*/
function dateWithUTCOffset(offsetStr) {
const match = offsetStr.trim().match(/^UTC\s*([+-])\s*(\d{1,2})(?::(\d{2}))?$/i);
if (!match) return null;
const sign = match[1] === '+' ? 1 : -1;
const hours = parseInt(match[2], 10);
const minutes = match[3] ? parseInt(match[3], 10) : 0;
if (hours > 14 || minutes > 59) return null;
const offsetMs = sign * (hours * 3600000 + minutes * 60000);
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
return new Date(utcMs + offsetMs);
}
// ================================================================
// === TRIM SENTINEL ===
// ================================================================
// Internal sentinel that {{trim}} expands to; post-processing collapses
// whitespace around it and removes the sentinel itself.
const TRIM_SENTINEL = '\x00__QOLMACRO_TRIM__\x00';
// ================================================================
// === MACRO HANDLERS ===
// ================================================================
/** @type {Object<string, (args: string[], ctx: object) => string>} */
const handlers = {};
// ────────────────────────────────────────────────────────────
// Randomization
// ────────────────────────────────────────────────────────────
handlers['random'] = (args) => {
if (args.length === 0) return '';
return args[Math.floor(Math.random() * args.length)];
};
handlers['pick'] = (args, ctx) => {
if (args.length === 0) return '';
// Build a seed from: context seed + options + occurrence index
const seedStr = (ctx.seed || 'default') + '|' + args.join('|') + '|' + (ctx.macroIndex || 0);
const hash = hashString(seedStr);
const idx = Math.floor(mulberry32(Math.abs(hash)) * args.length);
return args[idx];
};
handlers['roll'] = (args) => {
const notation = args[0] || '1d6';
const result = rollDice(notation);
return isNaN(result) ? `[Invalid dice: ${notation}]` : String(result);
};
// ────────────────────────────────────────────────────────────
// Date & Time
// ────────────────────────────────────────────────────────────
handlers['time'] = (args) => {
if (args.length > 0) {
const shifted = dateWithUTCOffset(args[0]);
if (shifted) return shifted.toLocaleTimeString();
return `[Invalid offset: ${args[0]}]`;
}
return new Date().toLocaleTimeString();
};
handlers['date'] = () => new Date().toLocaleDateString();
handlers['weekday'] = () => {
return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][new Date().getDay()];
};
handlers['isotime'] = () => {
const d = new Date();
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
};
handlers['isodate'] = () => {
const d = new Date();
return d.getFullYear()
+ '-' + String(d.getMonth() + 1).padStart(2, '0')
+ '-' + String(d.getDate()).padStart(2, '0');
};
handlers['datetimeformat'] = (args) => {
if (args.length === 0) return new Date().toISOString();
return formatDateTime(args[0]);
};
// ────────────────────────────────────────────────────────────
// Math
// ────────────────────────────────────────────────────────────
handlers['abs'] = (args) => {
const n = parseFloat(args[0]);
return isNaN(n) ? '[Invalid number]' : String(Math.abs(n));
};
handlers['ceil'] = (args) => {
const n = parseFloat(args[0]);
return isNaN(n) ? '[Invalid number]' : String(Math.ceil(n));
};
handlers['floor'] = (args) => {
const n = parseFloat(args[0]);
return isNaN(n) ? '[Invalid number]' : String(Math.floor(n));
};
handlers['round'] = (args) => {
const n = parseFloat(args[0]);
return isNaN(n) ? '[Invalid number]' : String(Math.round(n));
};
handlers['min'] = (args) => {
const nums = args.map(Number).filter(n => !isNaN(n));
return nums.length === 0 ? '[No valid numbers]' : String(Math.min(...nums));
};
handlers['max'] = (args) => {
const nums = args.map(Number).filter(n => !isNaN(n));
return nums.length === 0 ? '[No valid numbers]' : String(Math.max(...nums));
};
handlers['calc'] = (args) => {
const expr = args.join('::'); // rejoin in case :: was part of expression (unlikely but safe)
const result = safeMathEval(expr);
return isNaN(result) ? `[Invalid expression: ${expr}]` : String(result);
};
// ────────────────────────────────────────────────────────────
// Utility
// ────────────────────────────────────────────────────────────
handlers['newline'] = (args) => {
const count = Math.min(Math.max(parseInt(args[0], 10) || 1, 1), 100);
return '\n'.repeat(count);
};
handlers['space'] = (args) => {
const count = Math.min(Math.max(parseInt(args[0], 10) || 1, 1), 100);
return ' '.repeat(count);
};
handlers['trim'] = () => TRIM_SENTINEL;
handlers['reverse'] = (args) => {
return (args[0] || '').split('').reverse().join('');
};
handlers['noop'] = () => '';
handlers['//'] = () => ''; // Inline comment
handlers['length'] = (args) => {
return String((args[0] || '').length);
};
handlers['lowercase'] = (args) => {
return (args[0] || '').toLowerCase();
};
handlers['uppercase'] = (args) => {
return (args[0] || '').toUpperCase();
};
handlers['capitalize'] = (args) => {
const text = args[0] || '';
return text.charAt(0).toUpperCase() + text.slice(1);
};
handlers['replace'] = (args) => {
// {{replace::find::replacement::text}}
if (args.length < 3) return args[args.length - 1] || '';
const [find, replacement, ...rest] = args;
const text = rest.join('::');
return text.replace(find, replacement);
};
handlers['replaceall'] = (args) => {
// {{replaceall::find::replacement::text}}
if (args.length < 3) return args[args.length - 1] || '';
const [find, replacement, ...rest] = args;
const text = rest.join('::');
return text.split(find).join(replacement);
};
handlers['substr'] = (args) => {
// {{substr::start::length::text}}
if (args.length < 3) return '';
const start = parseInt(args[0], 10) || 0;
const length = parseInt(args[1], 10);
const text = args.slice(2).join('::');
if (isNaN(length)) return text.substring(start);
return text.substring(start, start + length);
};
handlers['input'] = () => {
const textarea = document.querySelector('textarea[class*="_chatInput_"], textarea[class*="_autoResize"], [class*="_inputArea_"] textarea');
return textarea ? textarea.value : '';
};
// ================================================================
// === TIER 2: MESSAGE LOGGER INTEGRATION ===
// ================================================================
let messageLoggerRef = null;
/**
* Register Tier 2 macros that depend on JaiMessageLogger.
* Called once when the Message Logger library is detected.
*
* @param {object} logger - The JaiMessageLogger API object
*/
function connectMessageLogger(logger) {
if (messageLoggerRef) return; // already connected
messageLoggerRef = logger;
// {{lastMessage}} — last message text regardless of role (highest index)
handlers['lastmessage'] = () => {
const botIdx = logger.bot.getIndex();
const userIdx = logger.user.getIndex();
if (botIdx < 0 && userIdx < 0) return '';
if (userIdx > botIdx) return logger.user.getText();
return logger.bot.getText();
};
// {{lastUserMessage}} — last user message text
handlers['lastusermessage'] = () => logger.user.getText() || '';
// {{lastCharMessage}} / {{lastBotMessage}} — last bot message text
handlers['lastcharmessage'] = () => logger.bot.getText() || '';
handlers['lastbotmessage'] = () => logger.bot.getText() || '';
// {{lastMessageId}} — index of the most recent message (bot or user)
handlers['lastmessageid'] = () => {
const botIdx = logger.bot.getIndex();
const userIdx = logger.user.getIndex();
return String(Math.max(botIdx, userIdx));
};
// {{currentSwipeId}} / {{lastSwipeId}} — current swipe index (1-based)
handlers['currentswipeid'] = () => {
const idx = logger.bot.getSwipeIndex();
return idx >= 0 ? String(idx + 1) : '1';
};
handlers['lastswipeid'] = handlers['currentswipeid'];
// {{messageCount}} — total messages in the DOM
handlers['messagecount'] = () => String(logger.getMessageCount());
// {{botStatus}} — bot status (Complete/Streaming/Editing)
handlers['botstatus'] = () => logger.bot.getStatus() || '';
// {{userStatus}} — user status (Complete/Editing)
handlers['userstatus'] = () => logger.user.getStatus() || '';
console.log(
'%c[QoL Macros]%c Tier 2 connected — %c9 message macros%c registered via JaiMessageLogger',
'color: #a78bfa; font-weight: bold',
'color: inherit',
'color: #34d399; font-weight: bold',
'color: inherit'
);
}
/**
* Attempt to detect JaiMessageLogger. Checks the global immediately,
* and also listens for the ready event in case it loads later.
*/
function detectMessageLogger() {
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// Already available?
if (win.JaiMessageLogger && win.JaiMessageLogger.ready) {
connectMessageLogger(win.JaiMessageLogger);
return;
}
// Listen for the ready event (Message Logger fires this on load)
window.addEventListener('jai-msglogger:ready', function onReady(e) {
if (e.detail && e.detail.ready) {
connectMessageLogger(e.detail);
}
window.removeEventListener('jai-msglogger:ready', onReady);
});
}
// ================================================================
// === BLOCK COMMENT STRIPPER ===
// ================================================================
/**
* Remove block comments: {{//}}...{{///}}
* Must run BEFORE the main macro loop (these aren't standard macros).
* @param {string} text
* @returns {string}
*/
function stripBlockComments(text) {
// Match {{//}} ... {{///}} (with optional whitespace inside braces)
return text.replace(/\{\{\s*\/\/\s*\}\}[\s\S]*?\{\{\s*\/\/\/\s*\}\}/g, '');
}
// ================================================================
// === MAIN PROCESSOR ===
// ================================================================
/**
* Process all {{macro}} placeholders in text.
*
* Resolution order:
* 1. Strip block comments {{//}}...{{///}}
* 2. Iteratively resolve innermost macros first (no {{ inside them)
* 3. Post-process: collapse whitespace around {{trim}} sentinels
*
* Unknown macros are left as-is so that JanitorAI's native
* {{user}} / {{char}} replacement still works downstream.
*
* @param {string} text - Input text with {{macro}} placeholders
* @param {object} [context] - Optional context
* @param {string} [context.seed] - Seed string for {{pick}} stability
* @returns {string} Processed text
*/
function process(text, context) {
if (!text || typeof text !== 'string') return text || '';
context = context || {};
// Step 1: Strip block comments
text = stripBlockComments(text);
// Step 2: Iteratively resolve innermost macros
const MAX_ITERATIONS = 20;
let macroIndex = 0;
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
// Match innermost macros: {{ ... }} where ... contains no { or }
const regex = /\{\{\s*([^{}]+?)\s*\}\}/g;
let match;
let found = false;
let result = '';
let lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
found = true;
const fullMatch = match[0];
const inner = match[1]; // already trimmed by the \s* in regex
let replacement;
// Check for inline comment (starts with //)
if (inner.trimStart().startsWith('//')) {
replacement = '';
} else {
const parsed = parseMacroCall(inner);
const handler = handlers[parsed.name];
if (handler) {
replacement = handler(parsed.args, { ...context, macroIndex });
macroIndex++;
} else {
// Unknown macro — leave untouched for downstream processing
replacement = fullMatch;
}
}
result += text.substring(lastIndex, match.index) + replacement;
lastIndex = match.index + fullMatch.length;
}
if (!found) break;
result += text.substring(lastIndex);
// If nothing changed (all unknown macros left as-is), stop
if (result === text) break;
text = result;
}
// Step 3: Post-process trim sentinels
// The sentinel collapses surrounding whitespace (spaces, tabs, newlines)
const trimRe = new RegExp('\\s*' + escapeRegex(TRIM_SENTINEL) + '\\s*', 'g');
text = text.replace(trimRe, '');
return text;
}
/** Escape a string for use in a RegExp. */
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ================================================================
// === DEMO ===
// ================================================================
function demo() {
const examples = [
['Random pick', '{{random::apple::banana::cherry}}'],
['Stable pick', '{{pick::red::green::blue}}'],
['Dice roll 1d20', '{{roll::1d20}}'],
['Dice roll 2d6+3', '{{roll::2d6+3}}'],
['Dice roll 4d8-2', '{{roll::4d8-2}}'],
['Dice (space sep)', '{{roll 1d20}}'],
['Time', '{{time}}'],
['Time UTC+9', '{{time::UTC+9}}'],
['Time UTC-5', '{{time::UTC-5}}'],
['Date', '{{date}}'],
['Weekday', '{{weekday}}'],
['ISO time', '{{isotime}}'],
['ISO date', '{{isodate}}'],
['Custom format', '{{datetimeformat::YYYY-MM-DD HH:mm:ss}}'],
['Custom format 2', '{{datetimeformat::dddd, MMMM D, YYYY h:mm A}}'],
['Newline', 'before{{newline}}after'],
['3 Newlines', 'top{{newline::3}}bottom'],
['Space', 'a{{space::5}}b'],
['Trim', ' hello {{trim}} world '],
['Reverse', '{{reverse::Hello World}}'],
['Noop', 'visible{{noop}}text'],
['Comment', 'before{{// this is removed}}after'],
['Block comment', 'before{{//}}this is all removed{{///}}after'],
['Nested macros', '{{random::{{roll::1d6}}::fixed}}'],
['Math: abs', '{{abs::-42}}'],
['Math: ceil', '{{ceil::3.2}}'],
['Math: floor', '{{floor::3.8}}'],
['Math: round', '{{round::3.5}}'],
['Math: min', '{{min::5::3::9::1}}'],
['Math: max', '{{max::5::3::9::1}}'],
['Math: calc', '{{calc::2 + 3 * 4}}'],
['Math: calc parens', '{{calc::(2 + 3) * 4}}'],
['Length', '{{length::hello world}}'],
['Lowercase', '{{lowercase::HELLO}}'],
['Uppercase', '{{uppercase::hello}}'],
['Capitalize', '{{capitalize::hello world}}'],
['Replace', '{{replace::world::Earth::hello world}}'],
['Replace all', '{{replaceall::o::0::foo boo moo}}'],
['Substr', '{{substr::0::5::Hello World}}'],
['Unknown (left as-is)', '{{user}} said {{char}}'],
['Input field', '{{input}}'],
];
// Tier 2 demos (only if Message Logger connected)
if (messageLoggerRef) {
examples.push(
['Last message', '{{lastMessage}}'],
['Last user msg', '{{lastUserMessage}}'],
['Last bot msg', '{{lastCharMessage}}'],
['Last message ID', '{{lastMessageId}}'],
['Current swipe', '{{currentSwipeId}}'],
['Message count', '{{messageCount}}'],
['Bot status', '{{botStatus}}'],
['User status', '{{userStatus}}'],
);
}
console.group('%c[QoL Macros] Demo', 'color: #a78bfa; font-weight: bold');
const rows = [];
for (const [label, input] of examples) {
const output = process(input, { seed: 'demo' });
rows.push({ Macro: label, Input: input, Output: output });
}
console.table(rows);
console.groupEnd();
}
// ================================================================
// === PUBLIC API ===
// ================================================================
const API = {
ready: true,
version: '1.0.0',
/** Process all macros in text. */
process,
/** Register a custom macro handler: fn(args, ctx) → string */
register(name, fn) {
if (typeof name !== 'string' || typeof fn !== 'function') {
console.error('[QoL Macros] register() requires (string, function)');
return;
}
handlers[name.toLowerCase()] = fn;
},
/** Remove a macro handler. Returns true if it existed. */
unregister(name) {
const key = name.toLowerCase();
if (handlers[key]) {
delete handlers[key];
return true;
}
return false;
},
/** Check if a macro handler exists. */
has(name) {
return !!handlers[name.toLowerCase()];
},
/** List all registered macro names. */
list() {
return Object.keys(handlers);
},
/** Run a demo of all built-in macros. */
demo,
/** Reference to JaiMessageLogger if connected, else null. */
get messageLogger() { return messageLoggerRef; },
/** True if Tier 2 message macros are active. */
get tier2Connected() { return messageLoggerRef !== null; },
};
// ================================================================
// === FETCH INTERCEPTOR (message macro replacement) ===
// ================================================================
/**
* Quick check: does text contain anything that looks like a known macro?
* Avoids JSON-parsing overhead on requests with no macros.
*/
const MACRO_DETECT_RE = /\{\{[^{}]+\}\}/;
/**
* Extract chat_id from the current URL (/chats/<id>).
* @returns {string|null}
*/
function getChatIdFromUrl() {
const match = window.location.pathname.match(/\/chats\/(\d+)/);
return match ? match[1] : null;
}
// ================================================================
// === TEXTAREA INTERCEPTOR (user messages) ===
// ================================================================
/**
* For user messages: replace macros directly in the chat textarea
* BEFORE the site reads it. This ensures React state, the XHR body,
* the displayed message, and generateAlpha all see the resolved text.
*
* Uses capture-phase listeners so we fire before the site's handlers.
*/
function initTextareaInterceptor() {
const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// React needs the native setter to properly update controlled components
const nativeTextareaSetter = Object.getOwnPropertyDescriptor(
target.HTMLTextAreaElement.prototype, 'value'
).set;
function getTextarea() {
return document.querySelector(
'textarea[class*="_chatTextarea_"], textarea[class*="_chatInput_"]'
);
}
function processMacrosInTextarea() {
const textarea = getTextarea();
if (!textarea) return false;
const text = textarea.value;
if (!text || !MACRO_DETECT_RE.test(text)) return false;
const chatId = getChatIdFromUrl();
const ctx = chatId ? { seed: chatId } : {};
const processed = process(text, ctx);
if (processed !== text) {
// Use the native setter so React detects the change
nativeTextareaSetter.call(textarea, processed);
// Fire input event so React updates its internal state
textarea.dispatchEvent(new Event('input', { bubbles: true }));
console.log(
'%c[QoL Macros]%c Replaced macros in textarea before send',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
console.groupCollapsed('[QoL Macros] Macro replacement details');
console.log('Before:', text);
console.log('After: ', processed);
console.groupEnd();
return true;
}
return false;
}
// ── Enter key (capture phase — fires before site's handler) ──
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey) {
const textarea = getTextarea();
if (textarea && e.target === textarea) {
processMacrosInTextarea();
}
}
}, true); // capture phase
// ── Send button click (capture phase) ──
document.addEventListener('click', (e) => {
const sendBtn = e.target.closest(
'button[aria-label="Send"], button[class*="_sendButton_"]'
);
if (sendBtn) {
processMacrosInTextarea();
}
}, true); // capture phase
console.log(
'%c[QoL Macros]%c Textarea interceptor active — macros will be resolved in chat input before sending',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
}
// ================================================================
// === XHR/FETCH INTERCEPTOR (bot messages only) ===
// ================================================================
/**
* For bot messages: intercept XHR/fetch POSTs to the messages endpoint
* and replace macros in the message body when is_bot is true.
*
* Bot messages don't come from a textarea — they arrive from the AI
* response and get saved via XHR, so network interception is the
* only way to process them.
*/
function initBotMessageInterceptor() {
const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// Match: /hampter/chats/<digits>/messages
const MESSAGES_URL_RE = /\/hampter\/chats\/\d+\/messages(?:\?|$)/;
// Match: /generateAlpha
const GENERATE_ALPHA_URL_RE = /\/generateAlpha(?:\?|$)/;
/**
* Attempt macro replacement on a JSON body string (bot messages only).
* Returns the (possibly modified) body string.
*/
function replaceMacrosInBotBody(bodyStr) {
if (typeof bodyStr !== 'string' || !MACRO_DETECT_RE.test(bodyStr)) return bodyStr;
try {
const parsed = JSON.parse(bodyStr);
// Only process bot messages
if (
parsed &&
parsed.is_bot === true &&
typeof parsed.message === 'string' &&
typeof parsed.chat_id !== 'undefined' &&
MACRO_DETECT_RE.test(parsed.message)
) {
const original = parsed.message;
const ctx = { seed: String(parsed.chat_id) };
parsed.message = process(original, ctx);
if (parsed.message !== original) {
console.log(
'%c[QoL Macros]%c Replaced macros in bot message',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
console.groupCollapsed('[QoL Macros] Bot macro replacement details');
console.log('Before:', original);
console.log('After: ', parsed.message);
console.groupEnd();
return JSON.stringify(parsed);
}
}
} catch (e) {
console.error('[QoL Macros] Bot interceptor error:', e);
}
return bodyStr;
}
/**
* Attempt macro replacement on a generateAlpha JSON body string.
* Processes userConfig.llm_prompt and userConfig.open_ai_jailbreak_prompt.
* Returns the (possibly modified) body string.
*/
function replaceMacrosInPrompts(bodyStr) {
if (typeof bodyStr !== 'string' || !MACRO_DETECT_RE.test(bodyStr)) return bodyStr;
try {
const parsed = JSON.parse(bodyStr);
if (!parsed || !parsed.userConfig) return bodyStr;
const cfg = parsed.userConfig;
const chatId = parsed.chat && parsed.chat.id;
const ctx = chatId ? { seed: String(chatId) } : {};
let changed = false;
if (typeof cfg.llm_prompt === 'string' && MACRO_DETECT_RE.test(cfg.llm_prompt)) {
const original = cfg.llm_prompt;
cfg.llm_prompt = process(original, ctx);
if (cfg.llm_prompt !== original) {
changed = true;
console.log(
'%c[QoL Macros]%c Replaced macros in llm_prompt',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
console.groupCollapsed('[QoL Macros] llm_prompt replacement details');
console.log('Before:', original);
console.log('After: ', cfg.llm_prompt);
console.groupEnd();
}
}
if (typeof cfg.open_ai_jailbreak_prompt === 'string' && MACRO_DETECT_RE.test(cfg.open_ai_jailbreak_prompt)) {
const original = cfg.open_ai_jailbreak_prompt;
cfg.open_ai_jailbreak_prompt = process(original, ctx);
if (cfg.open_ai_jailbreak_prompt !== original) {
changed = true;
console.log(
'%c[QoL Macros]%c Replaced macros in open_ai_jailbreak_prompt',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
console.groupCollapsed('[QoL Macros] open_ai_jailbreak_prompt replacement details');
console.log('Before:', original);
console.log('After: ', cfg.open_ai_jailbreak_prompt);
console.groupEnd();
}
}
if (changed) return JSON.stringify(parsed);
} catch (e) {
console.error('[QoL Macros] generateAlpha interceptor error:', e);
}
return bodyStr;
}
// ── XHR interceptor (primary — JanitorAI uses XHR for messages) ──
const XHR = target.XMLHttpRequest;
if (XHR) {
const origOpen = XHR.prototype.open;
const origSend = XHR.prototype.send;
XHR.prototype.open = function (method, url, ...rest) {
this._qolMethod = method;
this._qolUrl = url;
return origOpen.call(this, method, url, ...rest);
};
XHR.prototype.send = function (body) {
if (
this._qolMethod &&
this._qolMethod.toUpperCase() === 'POST' &&
body
) {
if (MESSAGES_URL_RE.test(this._qolUrl)) {
body = replaceMacrosInBotBody(body);
} else if (GENERATE_ALPHA_URL_RE.test(this._qolUrl)) {
body = replaceMacrosInPrompts(body);
}
}
return origSend.call(this, body);
};
}
// ── Fetch interceptor (fallback) ──
const originalFetch = target.fetch;
if (originalFetch) {
target.fetch = async function (...args) {
let [resource, config] = args;
const url = typeof resource === 'string' ? resource : (resource instanceof Request ? resource.url : '');
const method = (config && config.method) || (resource instanceof Request ? resource.method : 'GET');
if (method.toUpperCase() === 'POST' && config && config.body) {
if (MESSAGES_URL_RE.test(url)) {
config = Object.assign({}, config);
config.body = replaceMacrosInBotBody(config.body);
} else if (GENERATE_ALPHA_URL_RE.test(url)) {
config = Object.assign({}, config);
config.body = replaceMacrosInPrompts(config.body);
}
}
return originalFetch.apply(this, [resource, config]);
};
}
console.log(
'%c[QoL Macros]%c Bot message + generateAlpha interceptor active (XHR + fetch)',
'color: #a78bfa; font-weight: bold',
'color: inherit'
);
}
// ================================================================
// === BOOTSTRAP ===
// ================================================================
// Expose globally
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
win.QoLMacros = API;
// Detect Message Logger for Tier 2 macros
detectMessageLogger();
// Start intercepting chat messages
initTextareaInterceptor();
initBotMessageInterceptor();
// Fire a ready event for scripts waiting on the library
const readyEvent = new CustomEvent('qol-macros:ready', { detail: API });
window.dispatchEvent(readyEvent);
console.log(
'%c[QoL Macros]%c Library v' + API.version + ' loaded — %c' + API.list().length + ' macros%c available. Try QoLMacros.demo()',
'color: #a78bfa; font-weight: bold',
'color: inherit',
'color: #34d399; font-weight: bold',
'color: inherit'
);
})();