mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-05-17 18:51:43 -05:00
482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
/**
|
|
* 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).readIfExistsSync() || "{}");
|
|
} catch (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 filtered 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`<strong>You said:</strong> ${question}<br />`;
|
|
buf += `<strong>Our automated reply:</strong> ${Chat.formatText(response)}`;
|
|
if (!hideButton) {
|
|
buf += Utils.html`<hr /><button class="button" name="send" value="A: ${question}">`;
|
|
buf += `Send to the Help room if you weren't answered correctly or wanted to help </button>`;
|
|
}
|
|
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)}|<div class="infobox">${reply}</div>`);
|
|
const trimmedMessage = `<div class="infobox">${Answerer.visualize(message, true)}</div>`;
|
|
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 = [
|
|
`<code>/helpfilter stats</code> - Shows stats for the Help filter (matched lines and the FAQs that match them.)`,
|
|
`<code>/helpfilter keys</code> - View regex keys for the Help filter.`,
|
|
`<code>/helpfilter toggle [on | off]</code> - Enables or disables the Help filter. Requires: @ # &`,
|
|
`<code>/helpfilter add [input] => [faq]</code> - Adds regex made from the input string to the Help filter, to respond with [faq] to matches.`,
|
|
`<code>/helpfilter remove [faq], [regex index]</code> - removes the regex matching the [index] from the Help filter's responses for [faq].`,
|
|
`<code>/helpfilter queue [regex] => [faq]</code> - Adds [regex] for [faq] to the queue for Help staff to review.`,
|
|
`<code>/helpfilter approve [index]</code> - Approves the regex at position [index] in the queue for use in the Help filter.`,
|
|
`<code>/helpfilter deny [index]</code> - Denies the regex at position [index] in the Help filter queue.`,
|
|
`Indexes can be found in /helpfilter keys.`,
|
|
`Requires: @ # &`,
|
|
];
|
|
return this.sendReplyBox(help.join('<br/ >'));
|
|
},
|
|
};
|
|
|
|
export const pages: PageTable = {
|
|
helpfilter(args, user) {
|
|
const helpRoom = Answerer.getRoom();
|
|
if (!helpRoom) return `<h2>There is no room configured to use the help filter.</h2>`;
|
|
const canChange = helpRoom.auth.atLeast(user, '@');
|
|
let buf = '';
|
|
const refresh = (type: string, extra?: string[]) => {
|
|
let button = `<button class="button" name="send" value="/join view-helpfilter-${type}`;
|
|
button += `${extra ? `-${extra.join('-')}` : ''}" style="float: right">`;
|
|
button += `<i class="fa fa-refresh"></i> Refresh</button><br />`;
|
|
return button;
|
|
};
|
|
const back = `<br /><a roomid="view-helpfilter-">Back to all</a>`;
|
|
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 `<h2>Invalid date.</h2>`;
|
|
}
|
|
buf = `<div class="pad"><strong>Stats for the Help auto-response filter${date ? ` on ${date}` : ''}.</strong>`;
|
|
buf += `${back}${refresh('stats', [date])}<hr />`;
|
|
const stats = helpData.stats;
|
|
if (!stats) return `<h2>No stats.</h2>`;
|
|
this.title = `[Help Stats] ${date ? date : ''}`;
|
|
if (date) {
|
|
if (!stats[date]) return `<h2>No stats for ${date}.</h2>`;
|
|
buf += `<strong>Total messages answered: ${stats[date].total}</strong><hr />`;
|
|
buf += `<details><summary>All messages and the corresponding answers (FAQs):</summary>`;
|
|
if (!stats[date].matches) return `<h2>No logs.</h2>`;
|
|
for (const entry of stats[date].matches!) {
|
|
buf += `<small>Message:</small>${LogViewer.renderLine(entry.message)}`;
|
|
buf += `<small>FAQ: ${entry.faqName}</small><br />`;
|
|
buf += `<small>Regex: <code>${entry.regex}</code></small> <hr />`;
|
|
}
|
|
return LogViewer.linkify(buf);
|
|
}
|
|
buf += `<strong> No date specified.<br />`;
|
|
buf += `Dates with stats:</strong><br />`;
|
|
for (const key in stats) {
|
|
buf += `- <a roomid="view-helpfilter-stats-${key}">${key}</a> (${stats[key].total})<br />`;
|
|
}
|
|
break;
|
|
case 'keys':
|
|
this.title = '[Help Regexes]';
|
|
if (!this.can('show', null, helpRoom)) return;
|
|
buf = `<div class="pad"><h2>Help filter regexes and responses:</h2>${back}${refresh('keys')}<hr />`;
|
|
buf += Object.keys(helpData.pairs).map(item => {
|
|
const regexes = helpData.pairs[item];
|
|
if (regexes.length < 1) return null;
|
|
let buffer = `<details><summary>${item}</summary>`;
|
|
for (const regex of regexes) {
|
|
const index = regexes.indexOf(regex) + 1;
|
|
const button = `<button class="button" name="send"value="/hf remove ${item}, ${index}">Remove</button>`;
|
|
if (canChange) buffer += `- <small><code>${regex}</code> ${button} (index ${index})</small><br />`;
|
|
}
|
|
buffer += `</details>`;
|
|
return buffer;
|
|
}).filter(Boolean).join('<hr />');
|
|
break;
|
|
case 'queue':
|
|
this.title = `[Help Queue]`;
|
|
if (!this.can('show', null, helpRoom)) return;
|
|
buf = `<div class="pad"><h2>`;
|
|
buf += `${Answerer.queue.length > 0 ? 'R' : 'No r'}egexes queued for review.</h2>${back}${refresh('queue')}`;
|
|
if (!helpData.queue) helpData.queue = [];
|
|
for (const request of helpData.queue) {
|
|
const faq = request.split('=>')[1];
|
|
buf += `<hr /><strong>FAQ: ${faq}</strong><hr />`;
|
|
buf += `Input: ${request}<br />`;
|
|
buf += `Full regex: <code>${Answerer.stringRegex(request)}</code>`;
|
|
const index = helpData.queue.indexOf(request) + 1;
|
|
if (canChange) {
|
|
buf += `<br /><button class="button" name="send"value="/hf approve ${index}">Approve</button>`;
|
|
buf += `<button class="button" name="send"value="/hf deny ${index}">Deny</button>`;
|
|
}
|
|
buf += `<hr /><br />`;
|
|
}
|
|
buf += '</div>';
|
|
break;
|
|
default:
|
|
this.title = '[Help Filter]';
|
|
buf = `<div class="pad"><h2>Specify a filter page to view.</h2>`;
|
|
buf += `<hr /><strong>Options:</strong><hr />`;
|
|
buf += `<a roomid="view-helpfilter-stats">Stats</a><hr />`;
|
|
buf += `<a roomid="view-helpfilter-keys">Regex keys</a><hr/>`;
|
|
buf += `<a roomid="view-helpfilter-queue">Queue</a><hr/>`;
|
|
buf += `</div>`;
|
|
}
|
|
return LogViewer.linkify(buf);
|
|
},
|
|
};
|