mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-04-26 02:39:38 -05:00
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/**
|
|
* Neural net chatfilters.
|
|
* These are in a separate file so that they don't crash the other filters.
|
|
* (issues with globals, etc)
|
|
* by Mia.
|
|
* @author mia-pi-git
|
|
*/
|
|
|
|
import {QueryProcessManager} from '../../lib/process-manager';
|
|
import {FS, Utils} from '../../lib/';
|
|
import {Config} from '../config-loader';
|
|
import {Repl} from '../../lib/repl';
|
|
import {linkRegex} from '../chat-formatter';
|
|
|
|
const PATH = "config/chat-plugins/net.json";
|
|
const NUM_PROCESSES = Config.netfilterprocesses || 1;
|
|
const PM_TIMEOUT = 2 * 60 * 60 * 1000; // training can be _really_ slow
|
|
const WHITELIST = ["mia"];
|
|
|
|
interface NetQuery {
|
|
data: string | TrainingLine[] | AnyObject;
|
|
type: "run" | "train" | "save" | "load" | 'trainfrom';
|
|
options?: AnyObject;
|
|
}
|
|
// @ts-ignore in case the optional dependency is not installed
|
|
type NetModel = import('brain.js').recurrent.LSTM;
|
|
|
|
interface TrainingLine {
|
|
input: string;
|
|
// "|flag" (bad) "|ok" (good)
|
|
output: string;
|
|
}
|
|
|
|
function modelExists() {
|
|
try {
|
|
require.resolve('brain.js');
|
|
} catch {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function toRoomID(room: RoomID | Room) {
|
|
return ("" + room).toLowerCase().replace(/[^a-z0-9-]+/g, '');
|
|
}
|
|
|
|
export class NeuralNetChecker {
|
|
model: NetModel | null;
|
|
constructor(path?: string) {
|
|
try {
|
|
this.model = new (require('brain.js').recurrent.LSTM)();
|
|
} catch {
|
|
this.model = null;
|
|
}
|
|
if (path) this.load(path);
|
|
}
|
|
async train(data: TrainingLine[], iterations?: number) {
|
|
// 200 has good perf but is still effective
|
|
if (!iterations) iterations = 200;
|
|
const now = Date.now();
|
|
if (FS(PATH).existsSync()) await FS(PATH).copyFile(PATH + '.backup');
|
|
if (!this.model) throw new Error(`Attempting to train with no model installed`);
|
|
for (const line of data) {
|
|
try {
|
|
this.model.train([line], {iterations});
|
|
} catch (e) {
|
|
Monitor.crashlog(e, "a netfilter training process", {
|
|
line: JSON.stringify(line),
|
|
});
|
|
process.exit();
|
|
}
|
|
}
|
|
this.save();
|
|
return Date.now() - now; // time data is helpful for training
|
|
}
|
|
static sanitizeChatLines(content: string, result: string): TrainingLine[] {
|
|
return content.split('\n').map(line => {
|
|
const message = this.parseChatLine(line);
|
|
if (!message) return null;
|
|
return {output: `|${result}`, input: message};
|
|
}).filter(Boolean) as TrainingLine[];
|
|
}
|
|
static sanitizeLine(content: string) {
|
|
return content.replace(linkRegex, '').replace(/<<[a-z0-9-]+>>/ig, '');
|
|
}
|
|
static parseChatLine(line: string) {
|
|
const parts = Utils.splitFirst(line, '|', 4).map(i => i.trim());
|
|
if (
|
|
parts[1] !== 'c' || (parts[3].startsWith('/') && !parts[3].startsWith('/me')) ||
|
|
parts[3].startsWith('!') || parts[3].length < 3
|
|
) {
|
|
return null;
|
|
}
|
|
return this.sanitizeLine(parts[3]);
|
|
}
|
|
save(path = PATH) {
|
|
if (!this.model) return {};
|
|
const state = this.model.toJSON();
|
|
FS(path).writeUpdate(() => JSON.stringify(state));
|
|
return state;
|
|
}
|
|
load(path: string) {
|
|
if (!FS(path).existsSync()) return;
|
|
const data = JSON.parse(FS(path).readSync());
|
|
this.model?.fromJSON(data);
|
|
}
|
|
run(data: string) {
|
|
let result = '';
|
|
if (!this.model) return result;
|
|
try {
|
|
result = this.model.run(data);
|
|
} catch {}
|
|
// usually means someone didn't train it, carry on
|
|
// acceptable to drop since training is very slow
|
|
return result;
|
|
}
|
|
static async train(data: TrainingLine[]) {
|
|
// do the training in its own process
|
|
const result = await PM.queryTemporaryProcess({type: 'train', data});
|
|
// load it into the main process that we're querying
|
|
for (const sub of PM.processes) {
|
|
await sub.query({type: 'load', data: PATH});
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function checkAllowed(context: Chat.CommandContext) {
|
|
if (!modelExists()) throw new Chat.ErrorMessage(`Net filters are disabled - install brain.js to use them.`);
|
|
const user = context.user;
|
|
if (WHITELIST.includes(user.id)) return true;
|
|
return context.canUseConsole();
|
|
}
|
|
|
|
export let net: NeuralNetChecker | null = null;
|
|
export let disabled = false;
|
|
|
|
export const hits: {[roomid: string]: {[userid: string]: number}} = (() => {
|
|
const cache = Object.create(null);
|
|
if (global.Chat) {
|
|
if (Chat.plugins['net-filters']?.hits) {
|
|
Object.assign(cache, Chat.plugins['net-filters'].hits);
|
|
}
|
|
}
|
|
return cache;
|
|
})();
|
|
|
|
export const chatfilter: Chat.ChatFilter = function (message, user, room, connection) {
|
|
if (disabled || !modelExists()) return;
|
|
// not awaited as so to not hold up the filters (additionally we can wait on this)
|
|
void (async () => {
|
|
if (!room || room.persist || room.roomid.startsWith('help-')) return;
|
|
const result = await PM.query({type: "run", data: message});
|
|
if (result?.endsWith("|flag")) {
|
|
if (!hits[room.roomid]) hits[room.roomid] = {};
|
|
if (!hits[room.roomid][user.id]) hits[room.roomid][user.id] = 0;
|
|
hits[room.roomid][user.id]++;
|
|
const minCount = Config.netfilterlimit || 20;
|
|
if (hits[room.roomid][user.id] >= minCount) {
|
|
Rooms.get('upperstaff')?.add(
|
|
`|c|&|/log [ERPMonitor] Suspicious messages detected in <<${room.roomid}>>`
|
|
).update();
|
|
hits[room.roomid][user.id] = 0; // so they can't spam messages
|
|
if ('uploadReplay' in (room as GameRoom)) {
|
|
void (room as GameRoom).uploadReplay(user, connection, "silent");
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
return undefined;
|
|
};
|
|
|
|
export const PM = new QueryProcessManager<NetQuery, any>(module, async query => {
|
|
if (!net) throw new Error("Neural net not intialized");
|
|
const {data, type, options} = query;
|
|
switch (type) {
|
|
case 'run':
|
|
let response = '';
|
|
try {
|
|
response = net.run(data as string);
|
|
} catch {} // uninitialized (usually means intializing, which can be slow) - drop it for now
|
|
return response;
|
|
case 'train':
|
|
return net.train(data as TrainingLine[], options?.iterations);
|
|
case 'save':
|
|
return net.save();
|
|
case 'load':
|
|
try {
|
|
net.load(data as string);
|
|
} catch (e: any) {
|
|
return e.message;
|
|
}
|
|
return 'success';
|
|
case 'trainfrom':
|
|
const {path: targetPath, result} = data as AnyObject;
|
|
const content = FS(targetPath).readSync();
|
|
const lines = NeuralNetChecker.sanitizeChatLines(content, result);
|
|
const time = await net.train(lines);
|
|
return [time, lines.length];
|
|
}
|
|
}, PM_TIMEOUT);
|
|
|
|
if (!PM.isParentProcess) {
|
|
global.Config = Config;
|
|
|
|
global.Monitor = {
|
|
crashlog(error: Error, source = 'A netfilter process', details: AnyObject | null = null) {
|
|
const repr = JSON.stringify([error.name, error.message, source, details]);
|
|
process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
|
|
},
|
|
};
|
|
process.on('uncaughtException', err => {
|
|
if (Config.crashguard) {
|
|
Monitor.crashlog(err, 'A net filter child process');
|
|
}
|
|
});
|
|
// we only want to spawn one network, when it's the subprocess
|
|
// otherwise, we use the PM for interfacing with the network
|
|
net = new NeuralNetChecker(PATH);
|
|
// eslint-disable-next-line no-eval
|
|
Repl.start('netfilters', cmd => eval(cmd));
|
|
} else {
|
|
PM.spawn(NUM_PROCESSES);
|
|
}
|
|
|
|
export const commands: Chat.ChatCommands = {
|
|
netfilter: {
|
|
limit(target, room, user) {
|
|
checkAllowed(this);
|
|
const int = parseInt(target);
|
|
if (isNaN(int)) {
|
|
return this.errorReply("Invalid number");
|
|
}
|
|
if (int < 20) {
|
|
return this.errorReply("Too low.");
|
|
}
|
|
Config.netfilterlimit = int;
|
|
this.privateGlobalModAction(`${user.name} temporarily set the net filter trigger limit to ${int}`);
|
|
this.globalModlog(`NETFILTER LIMIT`, null, int.toString());
|
|
},
|
|
async train(target, room, user) {
|
|
checkAllowed(this);
|
|
const data: TrainingLine[] = [];
|
|
const parts = target.split('\n').filter(Boolean);
|
|
for (const line of parts) {
|
|
const [input, output] = Utils.splitFirst(line, '|');
|
|
if (!['ok', 'flag'].some(i => output === i)) {
|
|
return this.errorReply(`Malformed line: ${line} - output must be 'ok' or 'flag'.`);
|
|
}
|
|
if (!input.trim()) {
|
|
return this.errorReply(`Malformed line: ${line} - input must be a string`);
|
|
}
|
|
data.push({input, output: `|${output}`});
|
|
}
|
|
if (!data.length) {
|
|
return this.errorReply(`You need to provide some sort of data`);
|
|
}
|
|
this.sendReply(`Initiating training...`);
|
|
const results = await NeuralNetChecker.train(data.filter(Boolean));
|
|
this.sendReply(`Training completed in ${Chat.toDurationString(results)}`);
|
|
this.privateGlobalModAction(`${user.name} trained the net filters on ${Chat.count(data.length, 'lines')}`);
|
|
this.stafflog(`${data.map(i => `(lines: '${i.input}' => '${i.output}'`).join('; ')})`);
|
|
},
|
|
async rollback(target, room, user) {
|
|
checkAllowed(this);
|
|
const backup = FS(PATH + '.backup');
|
|
if (!backup.existsSync()) return this.errorReply(`No backup exists.`);
|
|
await backup.copyFile(PATH);
|
|
const result = await PM.query({type: "load", data: PATH});
|
|
if (result && result !== 'success') {
|
|
return this.errorReply(`Rollback failed: ${result}`);
|
|
}
|
|
this.privateGlobalModAction(`${user.name} rolled the net filters back to last backup`);
|
|
},
|
|
async test(target, room, user) {
|
|
checkAllowed(this);
|
|
const result = await PM.query({type: 'run', data: target});
|
|
return this.sendReply(`Result for '${target}': ${result}`);
|
|
},
|
|
enable: 'disable',
|
|
disable(target, room, user, connection, cmd) {
|
|
checkAllowed(this);
|
|
let logMessage;
|
|
if (cmd === 'disable') {
|
|
if (disabled) return this.errorReply(`Net filters are already disabled.`);
|
|
disabled = true;
|
|
this.globalModlog(`NETFILTER DISABLE`);
|
|
logMessage = `${user.name} disabled the net filters`;
|
|
} else {
|
|
if (!disabled) return this.errorReply(`The net filters are already enabled`);
|
|
disabled = false;
|
|
this.globalModlog(`NETFILTER ENABLE`);
|
|
logMessage = `${user.name} enabled the net filters`;
|
|
}
|
|
this.privateGlobalModAction(logMessage);
|
|
},
|
|
async trainfrom(target, room, user) {
|
|
checkAllowed(this);
|
|
let [roomid, date, result] = Utils.splitFirst(target, ',', 2).map(i => i.trim());
|
|
roomid = toRoomID(roomid as RoomID);
|
|
if (!FS('logs/chat/' + roomid.toLowerCase()).existsSync()) {
|
|
return this.errorReply(`Logs for that roomid not found.`);
|
|
}
|
|
if (!/\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b/ig.test(date)) {
|
|
return this.errorReply(`Invalid date`);
|
|
}
|
|
if (!['ok', 'flag'].includes(toID(result))) {
|
|
return this.errorReply(`Invalid output`);
|
|
}
|
|
const targetPath = FS(`logs/chat/${roomid}/${date.slice(0, -3)}/${date}.txt`);
|
|
if (!targetPath.existsSync()) return this.errorReply(`Logs for that date not found`);
|
|
this.sendReply(`Initating training...`);
|
|
const response = await PM.query({data: {path: targetPath.path, result}, type: 'trainfrom'});
|
|
this.sendReply(`Training completed in ${Chat.toDurationString(response[0])}`);
|
|
this.privateGlobalModAction(
|
|
`${user.name} trained the net filters on logs from ${roomid} (${date} - ${Chat.count(response[1], 'lines')})`
|
|
);
|
|
this.stafflog(`Result: ${result} | Time: ${response[0]}ms`);
|
|
},
|
|
},
|
|
};
|
|
|
|
if (global.Chat) {
|
|
process.nextTick(() => Chat.multiLinePattern.register('/netfilter train '));
|
|
}
|