/**
* PS Help room auto-response plugin.
* Uses Regex to match room frequently asked question (RFAQ) entries,
* and replies if a match is found.
* Supports configuration.
* Written by mia-pi.
*/
import {FS} from '../../lib/fs';
import {Utils} from '../../lib/utils';
import {LogViewer} from './chatlog';
import {ROOMFAQ_FILE} from './room-faqs';
const PATH = 'config/chat-plugins/help.json';
// 6: filters out conveniently short aliases
const MINIMUM_LENGTH = 6;
export let helpData: PluginData;
try {
helpData = JSON.parse(FS(PATH).readSync());
} catch (e) {
if (e.code !== 'ENOENT') throw e;
helpData = {
stats: {},
pairs: {},
disabled: false,
queue: [],
};
}
/**
* Terms commonly used in helping that should be ignored
* within question parsing (the Help#find function).
*/
const COMMON_TERMS = [
'<<(.+)>>', '(do|use|type)( |)(``|)(/|!|//)(.+)(``|)', 'go to', '/rfaq (.+)', 'you can', Chat.linkRegex, 'click',
'need to',
].map(item => new RegExp(item, "i"));
/**
* A message caught by the Help filter.
*/
interface LoggedMessage {
/** Message that's matched by the Help filter. */
message: string;
/** The FAQ that it's matched to. */
faqName: string;
/** The regex that it's matched to. */
regex: string;
}
/** Object of stats for that day. */
interface DayStats {
matches?: LoggedMessage[];
total?: number;
}
interface PluginData {
/** Stats - filter match and faq that was matched - done day by day. */
stats?: {[k: string]: DayStats};
/** Word pairs that have been marked as a match for a specific FAQ. */
pairs: {[k: string]: string[]};
/** Whether or not the filter is disabled. */
disabled?: boolean;
/** Queue of suggested regex. */
queue?: string[];
}
export class HelpResponder {
roomFaqs: AnyObject;
disabled?: boolean;
queue: string[];
data: PluginData;
constructor(data: PluginData) {
this.data = data;
this.roomFaqs = this.loadFaqs();
this.queue = data.queue || [];
FS(ROOMFAQ_FILE).onModify(() => {
// refresh on modifications to keep it up to date
this.roomFaqs = this.loadFaqs();
});
}
getRoom() {
return Config.helpFilterRoom ? Rooms.get(Config.helpFilterRoom) : Rooms.get('help');
}
find(question: string, user?: User) {
const faqs = Object.keys((this.roomFaqs || '{}'))
.filter(item => item.length >= MINIMUM_LENGTH && !this.roomFaqs[item].startsWith('>'));
if (COMMON_TERMS.some(t => t.test(question))) return null;
for (const faq of faqs) {
const match = this.match(question, faq);
if (match) {
if (user) {
const timestamp = Chat.toTimestamp(new Date()).split(' ')[1];
const log = `${timestamp} |c| ${user.name}|${question}`;
this.log(log, faq, match.regex);
}
return this.roomFaqs[match.faq];
}
}
return null;
}
visualize(question: string, hideButton?: boolean, user?: User) {
const response = this.find(question, user);
if (response) {
let buf = '';
buf += Utils.html`You said: ${question} `;
buf += `Our automated reply: ${Chat.formatText(response)}`;
if (!hideButton) {
buf += Utils.html`
`;
}
return buf;
}
return null;
}
getFaqID(faq: string) {
faq = faq.trim();
if (!faq) return;
const alias: string = this.roomFaqs[faq];
if (!alias) return;
// ignore short aliases, they cause too many false positives
if (faq.length <= MINIMUM_LENGTH || alias.length <= MINIMUM_LENGTH) return;
if (alias.charAt(0) !== '>') return faq; // not an alias
return alias.replace('>', '');
}
stringRegex(str: string, raw?: boolean) {
[str] = Utils.splitFirst(str, '=>');
const args = str.split(',').map(item => item.trim());
return args.map(item => {
const split = item.split('&').map(string => {
// allow raw regex for admins and whitelisted users
if (raw) return string.trim();
// escape otherwise
return string.replace(/[\\^$.*+?()[\]{}]/g, '\\$&').trim();
});
return split.map(term => {
if (term.startsWith('!')) {
return `^(?!.*${term.slice(1)})`;
}
if (!term.trim()) return null;
return `(?=.*?(${term.trim()}))`;
}).filter(Boolean).join('');
}).filter(Boolean).join('');
}
match(question: string, faq: string) {
if (!this.data.pairs[faq]) this.data.pairs[faq] = [];
const regexes = this.data.pairs[faq].map(item => new RegExp(item, "i"));
if (!regexes) return;
for (const regex of regexes) {
if (regex.test(Chat.normalize(question))) return {faq, regex: regex.toString()};
}
return;
}
log(entry: string, faq: string, expression: string) {
if (!this.data.stats) this.data.stats = {};
const [day] = Utils.splitFirst(Chat.toTimestamp(new Date), ' ');
if (!this.data.stats[day]) this.data.stats[day] = {};
const today = this.data.stats[day];
const log: LoggedMessage = {
message: entry,
faqName: faq,
regex: expression,
};
const stats = {
matches: today.matches || [],
total: today.matches ? today.matches.length : 0,
};
const dayLog = Object.assign(this.data.stats[day], stats);
dayLog.matches.push(log);
dayLog.total++;
return this.writeState();
}
writeState() {
return FS(PATH).writeUpdate(() => JSON.stringify(this.data));
}
loadFaqs() {
const room = this.getRoom();
if (!room) {
this.roomFaqs = {};
return this.roomFaqs;
}
const roomid = room.roomid;
this.roomFaqs = JSON.parse(FS(ROOMFAQ_FILE).readIfExistsSync() || `{"${roomid}":{}}`)[roomid];
for (const key in this.data.pairs) {
if (!this.roomFaqs[key]) delete this.data.pairs[key];
}
this.writeState();
return this.roomFaqs;
}
tryAddRegex(inputString: string, raw?: boolean) {
let [args, faq] = inputString.split('=>');
faq = this.getFaqID(toID(faq)) as string;
if (!faq) throw new Chat.ErrorMessage("Invalid FAQ.");
if (!this.data.pairs) this.data.pairs = {};
if (!this.data.pairs[faq]) this.data.pairs[faq] = [];
const regex = raw ? args.trim() : this.stringRegex(args, raw);
if (this.data.pairs[faq].includes(regex)) {
throw new Chat.ErrorMessage(`That regex is already stored.`);
}
Chat.validateRegex(regex);
this.data.pairs[faq].push(regex);
return this.writeState();
}
tryRemoveRegex(faq: string, index: number) {
faq = this.getFaqID(faq) as string;
if (!faq) throw new Chat.ErrorMessage("Invalid FAQ.");
if (!this.data.pairs) this.data.pairs = {};
if (!this.data.pairs[faq]) throw new Chat.ErrorMessage(`There are no regexes for ${faq}.`);
if (!this.data.pairs[faq][index]) throw new Chat.ErrorMessage("Your provided index is invalid.");
this.data.pairs[faq].splice(index, 1);
this.writeState();
return true;
}
}
export const Answerer = new HelpResponder(helpData);
export const chatfilter: ChatFilter = (message, user, room) => {
const helpRoom = Answerer.getRoom();
if (!helpRoom) return message;
if (room?.roomid === helpRoom.roomid && helpRoom.auth.get(user.id) === ' ' && !Answerer.disabled) {
const reply = Answerer.visualize(message, false, user);
if (message.startsWith('/') || message.startsWith('!')) return message;
if (!reply) {
return message;
} else {
if (message.startsWith('a:') || message.startsWith('A:')) return message.replace(/(a|A):/, '');
user.sendTo(room.roomid, `|uhtml|askhelp-${user}-${toID(message)}|
${reply}
`);
const trimmedMessage = `
${Answerer.visualize(message, true)}
`;
setTimeout(() => {
user.sendTo(
room.roomid,
`|c| ${user.name}|/uhtmlchange askhelp-${user}-${toID(message)}, ${trimmedMessage}`
);
}, 10 * 1000);
return false;
}
}
};
export const commands: ChatCommands = {
question(target, room, user) {
if (!Answerer.getRoom()) return this.errorReply(`There is no room configured for use of the Help filter.`);
if (!target) return this.parse("/help question");
const reply = Answerer.visualize(target, true);
if (!reply) return this.sendReplyBox(`No answer found.`);
this.runBroadcast();
this.sendReplyBox(reply);
},
questionhelp: ["/question [question] - Asks the Help room auto-response plugin a question."],
hf: 'helpfilter',
helpfilter: {
''(target) {
if (!Answerer.getRoom()) return this.errorReply(`There is no room configured for use of the Help filter.`);
if (!target) {
this.parse('/help helpfilter');
return this.sendReply(`The Help auto-response filter is currently set to: ${Answerer.disabled ? 'OFF' : "ON"}`);
}
return this.parse(`/j view-helpfilter-${target}`);
},
view(target, room, user) {
return this.parse(`/join view-helpfilter-${target}`);
},
toggle(target, room, user) {
if (!room) return this.requiresRoom();
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (room.roomid !== helpRoom.roomid) return this.errorReply(`This command is only available in the Help room.`);
if (!target) {
return this.sendReply(`The Help auto-response filter is currently set to: ${Answerer.disabled ? 'OFF' : "ON"}`);
}
if (!this.can('ban', null, room)) return false;
if (this.meansYes(target)) {
if (!Answerer.disabled) return this.errorReply(`The Help auto-response filter is already enabled.`);
Answerer.disabled = false;
}
if (this.meansNo(target)) {
if (Answerer.disabled) return this.errorReply(`The Help auto-response filter is already disabled.`);
Answerer.disabled = true;
}
Answerer.writeState();
this.privateModAction(`${user.name} ${Answerer.disabled ? 'disabled' : 'enabled'} the Help auto-response filter.`);
this.modlog(`HELPFILTER`, null, Answerer.disabled ? 'OFF' : 'ON');
},
forceadd: 'add',
add(target, room, user, connection, cmd) {
if (!room) return this.requiresRoom();
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (room.roomid !== helpRoom.roomid) return this.errorReply(`This command is only available in the Help room.`);
const force = cmd === 'forceadd';
const devAuth = Rooms.get('development')?.auth;
const canForce = devAuth?.atLeast(user, '%') && devAuth?.has(user.id);
if (force && (!canForce && !user.can('rangeban'))) {
return this.errorReply(`You cannot use raw regex - use /helpfilter add instead.`);
}
if (!this.can('ban', null, helpRoom)) return false;
Answerer.tryAddRegex(target, force);
this.privateModAction(`${user.name} added regex for "${target.split('=>')[0]}" to the filter.`);
this.modlog(`HELPFILTER ADD`, null, target);
},
remove(target, room, user) {
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (!this.can('ban', null, helpRoom)) return false;
const [faq, index] = target.split(',');
// intended for use mainly within the page, so supports being used in all rooms
this.room = helpRoom;
const num = parseInt(index);
if (isNaN(num)) return this.errorReply("Invalid index.");
Answerer.tryRemoveRegex(faq, num - 1);
this.privateModAction(`${user.name} removed regex ${num} from the usable regexes for ${faq}.`);
this.modlog('HELPFILTER REMOVE', null, index);
},
queue(target, room, user) {
if (!room) return this.requiresRoom();
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (room.roomid !== helpRoom.roomid) return this.errorReply(`This command is only available in the Help room.`);
if (!this.can('show', null, helpRoom)) return false;
if (!room.auth.has(user.id)) return this.errorReply(`Only roomauth can submit regexes to the filter.`);
if (!target) return this.errorReply(`Specify regex.`);
const faq = Answerer.getFaqID(target.split('=>')[1]);
if (!faq) return this.errorReply(`Invalid FAQ.`);
const regex = Answerer.stringRegex(target);
if (Answerer.queue.includes(target)) {
return this.errorReply(`That regex string is already in queue.`);
}
Chat.validateRegex(regex);
Answerer.queue.push(target);
Answerer.writeState();
return this.sendReply(`Added "${target}" to the regex suggestion queue.`);
},
approve(target, room, user) {
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (!this.can('ban', null, helpRoom)) return false;
// intended for use mainly within the page, so supports being used in all rooms
this.room = helpRoom;
const index = parseInt(target) - 1;
if (isNaN(index)) return this.errorReply(`Invalid queue index.`);
const str = Answerer.queue[index];
if (!str) return this.errorReply(`Item does not exist in queue.`);
const regex = Answerer.stringRegex(str);
// validated on submission
const faq = Answerer.getFaqID(str.split('=>')[1].trim());
if (!faq) return this.errorReply(`Invalid FAQ.`);
if (!Answerer.data.pairs[faq]) helpData.pairs[faq] = [];
Answerer.data.pairs[faq].push(regex);
Answerer.queue.splice(index, 1);
Answerer.writeState();
this.privateModAction(`${user.name} approved regex for use with queue number ${target}`);
this.modlog(`HELPFILTER APPROVE`, null, `${target}: ${str}`);
},
deny(target, room, user) {
const helpRoom = Answerer.getRoom();
if (!helpRoom) return this.errorReply(`There is no room configured for use of this filter.`);
if (!this.can('ban', null, helpRoom)) return false;
// intended for use mainly within the page, so supports being used in all rooms
this.room = helpRoom;
target = target.trim();
const index = parseInt(target) - 1;
if (isNaN(index)) return this.errorReply(`Invalid queue index.`);
if (!Answerer.queue[index]) throw new Chat.ErrorMessage(`Item does not exist in queue.`);
Answerer.queue.splice(index, 1);
Answerer.writeState();
this.privateModAction(`${user.name} denied regex with queue number ${target}`);
this.modlog(`HELPFILTER DENY`, null, `${target}`);
},
},
helpfilterhelp() {
const help = [
`/helpfilter stats - Shows stats for the Help filter (matched lines and the FAQs that match them.)`,
`/helpfilter keys - View regex keys for the Help filter.`,
`/helpfilter toggle [on | off] - Enables or disables the Help filter. Requires: @ # &`,
`/helpfilter add [input] => [faq] - Adds regex made from the input string to the Help filter, to respond with [faq] to matches.`,
`/helpfilter remove [faq], [regex index] - removes the regex matching the [index] from the Help filter's responses for [faq].`,
`/helpfilter queue [regex] => [faq] - Adds [regex] for [faq] to the queue for Help staff to review.`,
`/helpfilter approve [index] - Approves the regex at position [index] in the queue for use in the Help filter.`,
`/helpfilter deny [index] - Denies the regex at position [index] in the Help filter queue.`,
`Indexes can be found in /helpfilter keys.`,
`Requires: @ # &`,
];
return this.sendReplyBox(help.join(' '));
},
};
export const pages: PageTable = {
helpfilter(args, user) {
const helpRoom = Answerer.getRoom();
if (!helpRoom) return `
There is no room configured to use the help filter.
`;
const canChange = helpRoom.auth.atLeast(user, '@');
let buf = '';
const refresh = (type: string, extra?: string[]) => {
let button = ` `;
return button;
};
const back = ` Back to all`;
switch (args[0]) {
case 'stats':
args.shift();
if (!this.can('mute', null, helpRoom)) return;
const date = args.join('-') || '';
if (!!date && isNaN(new Date(date).getTime())) {
return `
Invalid date.
`;
}
buf = `
Stats for the Help auto-response filter${date ? ` on ${date}` : ''}.`;
buf += `${back}${refresh('stats', [date])}`;
const stats = helpData.stats;
if (!stats) return `
No stats.
`;
this.title = `[Help Stats] ${date ? date : ''}`;
if (date) {
if (!stats[date]) return `
No stats for ${date}.
`;
buf += `Total messages answered: ${stats[date].total}`;
buf += `All messages and the corresponding answers (FAQs):`;
if (!stats[date].matches) return `
No logs.
`;
for (const entry of stats[date].matches!) {
buf += `Message:${LogViewer.renderLine(entry.message)}`;
buf += `FAQ: ${entry.faqName} `;
buf += `Regex: ${entry.regex}`;
}
return LogViewer.linkify(buf);
}
buf += ` No date specified. `;
buf += `Dates with stats: `;
for (const key in stats) {
buf += `- ${key} (${stats[key].total}) `;
}
break;
case 'keys':
this.title = '[Help Regexes]';
if (!this.can('show', null, helpRoom)) return;
buf = `
Help filter regexes and responses:
${back}${refresh('keys')}`;
buf += Object.keys(helpData.pairs).map(item => {
const regexes = helpData.pairs[item];
if (regexes.length < 1) return null;
let buffer = `${item}`;
for (const regex of regexes) {
const index = regexes.indexOf(regex) + 1;
const button = ``;
if (canChange) buffer += `- ${regex} ${button} (index ${index}) `;
}
buffer += ``;
return buffer;
}).filter(Boolean).join('');
break;
case 'queue':
this.title = `[Help Queue]`;
if (!this.can('show', null, helpRoom)) return;
buf = `