/** * 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 = `

`; buf += `${Answerer.queue.length > 0 ? 'R' : 'No r'}egexes queued for review.

${back}${refresh('queue')}`; if (!helpData.queue) helpData.queue = []; for (const request of helpData.queue) { const faq = request.split('=>')[1]; buf += `
FAQ: ${faq}
`; buf += `Input: ${request}
`; buf += `Full regex: ${Answerer.stringRegex(request)}`; const index = helpData.queue.indexOf(request) + 1; if (canChange) { buf += `
`; buf += ``; } buf += `

`; } buf += '
'; break; default: this.title = '[Help Filter]'; buf = `

Specify a filter page to view.

`; buf += `
Options:
`; buf += `Stats
`; buf += `Regex keys
`; buf += `Queue
`; buf += `
`; } return LogViewer.linkify(buf); }, };