<commit_before>/**
 * Neural net chat'filters'.
 * These are in a separate file so that they don't crash the other filters.
 * (issues with globals, etc)
 * We use Google's Perspective API to classify messages.
 * @see https://perspectiveapi.com/
 * by Mia.
 * @author mia-pi-git
 */

import {FS, Utils, Net, ProcessManager, Repl} from '../../lib';
import {Config} from '../config-loader';
import {toID} from '../../sim/dex-data';

const WHITELIST = ["mia"];
// 20m. this is mostly here so we can use Monitor.slow()
const PM_TIMEOUT = 20 * 60 * 1000;
const ATTRIBUTES = {
	"SEVERE_TOXICITY": {},
	"TOXICITY": {},
	"IDENTITY_ATTACK": {},
	"INSULT": {},
	"PROFANITY": {},
	"THREAT": {},
	"SEXUALLY_EXPLICIT": {},
	"FLIRTATION": {},
};
const NOJOIN_COMMAND_WHITELIST: {[k: string]: string} = {
	'lock': '/lock',
	'weeklock': '/weeklock',
	'warn': '/warn',
	'weeknamelock': '/wnl',
	'namelock': '/nl',
};
const REPORT_NAMECOLORS: {[k: string]: string} = {
	p1: 'DodgerBlue',
	p2: 'Crimson',
	p3: '#FBa92C',
	p4: '#228B22',
	other: '', // black - empty since handled by dark mode
};

export const cache: {
	[roomid: string]: {
		users: Record<string, number>,
		staffNotified?: ID,
		claimed?: ID,
	},
} = global.Chat?.oldPlugins['abuse-monitor']?.cache || {};

const defaults: FilterSettings = {
	threshold: 4,
	minScore: 0.65,
	specials: {
		THREAT: {0.96: 'MAXIMUM'},
		IDENTITY_ATTACK: {0.8: 2},
		SEVERE_TOXICITY: {0.8: 2},
	},
};

export const settings: FilterSettings = (() => {
	try {
		// accounting for data changes -
		// make sure we do have the default data in case it's not in the stored data
		return {...defaults, ...JSON.parse(FS('config/chat-plugins/nf.json').readSync())};
	} catch (e: any) {
		if (e.code !== "ENOENT") throw e;
		return defaults;
	}
})();

interface FilterSettings {
	disabled?: boolean;
	threshold: number;
	minScore: number;
	specials: {[k: string]: {[k: number]: number | "MAXIMUM"}};
}

export interface PerspectiveRequest {
	languages: string[];
	requestedAttributes: AnyObject;
	comment: {text: string};
}

interface PMResult {
	score: number;
	flags: string[];
	response?: Record<string, number>;
}

interface PMRequest {
	comment: string;
	fullResponse?: boolean;
}

interface BattleInfo {
	players: Record<SideID, ID>;
	log: string[];
}

// Mostly stolen from my code in helptickets.
// Necessary because we can't require this in without also requiring in a LOT of other
// modules, most of which crash the child process. Lot messier to fix that than it is to do this.
export function getBattleLog(battle: string) {
	const battleRoom = Rooms.get(battle);
	if (battleRoom && battleRoom.type !== 'chat') {
		const playerTable: Partial<BattleInfo['players']> = {};
		// i kinda hate this, but this will always be accurate to the battle players.
		// consulting room.battle.playerTable might be invalid (if battle is over), etc.
		const playerLines = battleRoom.log.log.filter(line => line.startsWith('|player|'));
		for (const line of playerLines) {
			const [, , playerSlot, name] = line.split('|');
			playerTable[playerSlot as SideID] = toID(name);
		}
		return {
			log: battleRoom.log.log.filter(k => k.startsWith('|c|')),
			players: playerTable as BattleInfo['players'],
		};
	}
	return null;
}
// see above comment.
function colorName(id: ID, info: BattleInfo) {
	for (const k in info.players) {
		const player = info.players[k as SideID];
		if (player === id) {
			return ` style="color: ${REPORT_NAMECOLORS[k]}"`;
		}
	}
	return REPORT_NAMECOLORS.other;
}

function time() {
	return Math.floor(Date.now() / 1000);
}

export class RollingCounter {
	counts: number[] = [0];
	readonly size: number;
	constructor(limit: number) {
		this.size = limit;
	}
	increment() {
		this.counts[this.counts.length - 1]++;
	}
	rollOver(amount: number) {
		if (amount > this.size) {
			this.counts = Array(this.size).fill(0);
			return;
		}
		for (let i = 0; i < amount; i++) {
			this.counts.push(0);
			if (this.counts.length > this.size) this.counts.shift();
		}
	}
	mean() {
		let total = 0;
		for (const elem of this.counts) total += elem;
		return total / this.counts.length;
	}
}

export class Limiter {
	readonly counter: RollingCounter;
	readonly max: number;
	lastCounterRoll = time();
	constructor(max: number, period: number) {
		this.max = max;
		this.counter = new RollingCounter(period);
	}
	shouldRequest() {
		const now = time();
		this.counter.rollOver(now - this.lastCounterRoll);
		this.lastCounterRoll = now;

		if (this.counter.mean() > this.max) return false;
		this.counter.increment();
		return true;
	}
}

function isCommon(message: string) {
	message = message.toLowerCase().replace(/\?!\., ;:/g, '');
	return ['gg', 'wp', 'ggwp', 'gl', 'hf', 'glhf', 'hello'].includes(message);
}

const limiter = new Limiter(15, 10);
let throttleTime: number | null = null;
export async function classify(text: string) {
	if (isCommon(text) || !limiter.shouldRequest()) return null;
	if (throttleTime && (Date.now() - throttleTime < 10000)) {
		return null;
	}
	if (throttleTime) throttleTime = null;

	const requestData: PerspectiveRequest = {
		// todo - support 'es', 'it', 'pt', 'fr' - use user.language? room.settings.language...?
		languages: ['en'],
		requestedAttributes: ATTRIBUTES,
		comment: {text},
	};
	try {
		const raw = await Net(`https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze`).post({
			query: {
				key: Config.perspectiveKey,
			},
			body: JSON.stringify(requestData),
			headers: {
				'Content-Type': "application/json",
			},
			timeout: 10 * 1000, // 10s
		});
		if (!raw) return null;
		const data = JSON.parse(raw);
		if (data.error) throw new Error(data.message);
		const result: {[k: string]: number} = {};
		for (const k in data.attributeScores) {
			const score = data.attributeScores[k];
			result[k] = score.summaryScore.value;
		}
		return result;
	} catch (e: any) {
		throttleTime = Date.now();
		if (e.message.startsWith('Request timeout')) {
			// just ignore this. error on their end not ours.
			// todo maybe stop sending requests for a bit?
			return null;
		}
		Monitor.crashlog(e, 'A Perspective API request', {request: JSON.stringify(requestData)});
		return null;
	}
}

export const PM = new ProcessManager.QueryProcessManager<PMRequest, PMResult>(module, async query => {
	const now = Date.now();
	const result = await classify(query.comment);
	if (!result) return {score: 0, flags: []};
	const delta = Date.now() - now;
	if (delta > 1000) {
		Monitor.slow(`[Abuse Monitor] ${delta}ms - ${JSON.stringify(query)}`);
	}
	let score = 0;
	const flags = new Set<string>();
	for (const type in result) {
		const data = result[type];
		if (settings.minScore && data < settings.minScore) continue;
		const curScore = score;
		if (settings.specials[type]) {
			for (const k in settings.specials[type]) {
				if (data < Number(k)) continue;
				const num = settings.specials[type][k];
				if (num === 'MAXIMUM') {
					score = settings.threshold;
				} else {
					if (num > score) score = num;
				}
			}
		}
		if (settings.minScore) {
			// min score ensures that if a category is above that minimum score, they will get
			// at least a point.
			// we previously ensured that this was above minScore if set, so this is fine
			if (score < 1) score = 1;
		}
		if (score !== curScore) flags.add(type);
	}
	return {
		score,
		flags: [...flags],
		// undefined so that json.stringify ignores it - save bandwidth
		response: query.fullResponse ? result : undefined,
	};
}, PM_TIMEOUT, message => {
	if (message.startsWith('SLOW\n')) {
		Monitor.slow(message.slice(5));
	}
});

if (!PM.isParentProcess) {
	// This is a child process!
	global.Config = Config;
	global.Monitor = {
		crashlog(error: Error, source = 'An abuse monitor child process', details: AnyObject | null = null) {
			const repr = JSON.stringify([error.name, error.message, source, details]);
			process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
		},
		slow(text: string) {
			process.send!(`CALLBACK\nSLOW\n${text}`);
		},
	};
	global.toID = toID;
	process.on('uncaughtException', err => {
		if (Config.crashguard) {
			Monitor.crashlog(err, 'A netfilter child process');
		}
	});
	// eslint-disable-next-line no-eval
	Repl.start(`abusemonitor-${process.pid}`, cmd => eval(cmd));
} else {
	PM.spawn(Config.netfilterprocesses || 1);
}

export const chatfilter: Chat.ChatFilter = function (message, user, room) {
	if (!room?.battle?.rated || settings.disabled || cache[room.roomid]?.staffNotified) return;
	// startsWith('!') - broadcasting command, ignore it.
	if (!Config.perspectiveKey || message.startsWith('!')) return;

	const roomid = room.roomid;
	void (async () => {
		const {score, flags} = await PM.query({comment: message});
		if (score) {
			if (!cache[roomid]) cache[roomid] = {users: {}};
			if (!cache[roomid].users[user.id]) cache[roomid].users[user.id] = 0;
			cache[roomid].users[user.id] += score;
			let hitThreshold = 0;
			if (cache[roomid].users[user.id] >= settings.threshold) {
				cache[roomid].staffNotified = user.id;
				notifyStaff();
				hitThreshold = 1;
				void room?.uploadReplay?.(user, this.connection, "forpunishment");
			}
			await Chat.database.run(
				'INSERT INTO perspective_logs (userid, message, score, flags, roomid, time, hit_threshold) VALUES (?, ?, ?, ?, ?, ?, ?)',
				[user.id, message, score, Utils.sortBy(flags).join(','), roomid, Date.now(), hitThreshold]
			);
		}
	})();
};
// to avoid conflicts with other filters
chatfilter.priority = -100;

export const handlers: Chat.Handlers = {
	onRoomDestroy(roomid) {
		if (cache[roomid]) delete cache[roomid];
	},
	onRoomClose(roomid) {
		if (!roomid.startsWith('view-abusemonitor-view')) return;
		const targetId = roomid.slice('view-abusemonitor-view-'.length);
		if (cache[targetId]) {
			delete cache[targetId].claimed;
			notifyStaff();
		}
	},
};

function getFlaggedRooms() {
	return Object.keys(cache).filter(roomid => cache[roomid].staffNotified);
}

function saveSettings() {
	FS('config/chat-plugins/nf.json').writeUpdate(() => JSON.stringify(settings));
}
<commit_msg>Abuse-monitor: Fix bug with unclaiming<commit_after>/**
 * Neural net chat'filters'.
 * These are in a separate file so that they don't crash the other filters.
 * (issues with globals, etc)
 * We use Google's Perspective API to classify messages.
 * @see https://perspectiveapi.com/
 * by Mia.
 * @author mia-pi-git
 */

import {FS, Utils, Net, ProcessManager, Repl} from '../../lib';
import {Config} from '../config-loader';
import {toID} from '../../sim/dex-data';

const WHITELIST = ["mia"];
// 20m. this is mostly here so we can use Monitor.slow()
const PM_TIMEOUT = 20 * 60 * 1000;
const ATTRIBUTES = {
	"SEVERE_TOXICITY": {},
	"TOXICITY": {},
	"IDENTITY_ATTACK": {},
	"INSULT": {},
	"PROFANITY": {},
	"THREAT": {},
	"SEXUALLY_EXPLICIT": {},
	"FLIRTATION": {},
};
const NOJOIN_COMMAND_WHITELIST: {[k: string]: string} = {
	'lock': '/lock',
	'weeklock': '/weeklock',
	'warn': '/warn',
	'weeknamelock': '/wnl',
	'namelock': '/nl',
};
const REPORT_NAMECOLORS: {[k: string]: string} = {
	p1: 'DodgerBlue',
	p2: 'Crimson',
	p3: '#FBa92C',
	p4: '#228B22',
	other: '', // black - empty since handled by dark mode
};

export const cache: {
	[roomid: string]: {
		users: Record<string, number>,
		staffNotified?: ID,
		claimed?: ID,
	},
} = global.Chat?.oldPlugins['abuse-monitor']?.cache || {};

const defaults: FilterSettings = {
	threshold: 4,
	minScore: 0.65,
	specials: {
		THREAT: {0.96: 'MAXIMUM'},
		IDENTITY_ATTACK: {0.8: 2},
		SEVERE_TOXICITY: {0.8: 2},
	},
};

export const settings: FilterSettings = (() => {
	try {
		// accounting for data changes -
		// make sure we do have the default data in case it's not in the stored data
		return {...defaults, ...JSON.parse(FS('config/chat-plugins/nf.json').readSync())};
	} catch (e: any) {
		if (e.code !== "ENOENT") throw e;
		return defaults;
	}
})();

interface FilterSettings {
	disabled?: boolean;
	threshold: number;
	minScore: number;
	specials: {[k: string]: {[k: number]: number | "MAXIMUM"}};
}

export interface PerspectiveRequest {
	languages: string[];
	requestedAttributes: AnyObject;
	comment: {text: string};
}

interface PMResult {
	score: number;
	flags: string[];
	response?: Record<string, number>;
}

interface PMRequest {
	comment: string;
	fullResponse?: boolean;
}

interface BattleInfo {
	players: Record<SideID, ID>;
	log: string[];
}

// Mostly stolen from my code in helptickets.
// Necessary because we can't require this in without also requiring in a LOT of other
// modules, most of which crash the child process. Lot messier to fix that than it is to do this.
export function getBattleLog(battle: string) {
	const battleRoom = Rooms.get(battle);
	if (battleRoom && battleRoom.type !== 'chat') {
		const playerTable: Partial<BattleInfo['players']> = {};
		// i kinda hate this, but this will always be accurate to the battle players.
		// consulting room.battle.playerTable might be invalid (if battle is over), etc.
		const playerLines = battleRoom.log.log.filter(line => line.startsWith('|player|'));
		for (const line of playerLines) {
			const [, , playerSlot, name] = line.split('|');
			playerTable[playerSlot as SideID] = toID(name);
		}
		return {
			log: battleRoom.log.log.filter(k => k.startsWith('|c|')),
			players: playerTable as BattleInfo['players'],
		};
	}
	return null;
}
// see above comment.
function colorName(id: ID, info: BattleInfo) {
	for (const k in info.players) {
		const player = info.players[k as SideID];
		if (player === id) {
			return ` style="color: ${REPORT_NAMECOLORS[k]}"`;
		}
	}
	return REPORT_NAMECOLORS.other;
}

function time() {
	return Math.floor(Date.now() / 1000);
}

export class RollingCounter {
	counts: number[] = [0];
	readonly size: number;
	constructor(limit: number) {
		this.size = limit;
	}
	increment() {
		this.counts[this.counts.length - 1]++;
	}
	rollOver(amount: number) {
		if (amount > this.size) {
			this.counts = Array(this.size).fill(0);
			return;
		}
		for (let i = 0; i < amount; i++) {
			this.counts.push(0);
			if (this.counts.length > this.size) this.counts.shift();
		}
	}
	mean() {
		let total = 0;
		for (const elem of this.counts) total += elem;
		return total / this.counts.length;
	}
}

export class Limiter {
	readonly counter: RollingCounter;
	readonly max: number;
	lastCounterRoll = time();
	constructor(max: number, period: number) {
		this.max = max;
		this.counter = new RollingCounter(period);
	}
	shouldRequest() {
		const now = time();
		this.counter.rollOver(now - this.lastCounterRoll);
		this.lastCounterRoll = now;

		if (this.counter.mean() > this.max) return false;
		this.counter.increment();
		return true;
	}
}

function isCommon(message: string) {
	message = message.toLowerCase().replace(/\?!\., ;:/g, '');
	return ['gg', 'wp', 'ggwp', 'gl', 'hf', 'glhf', 'hello'].includes(message);
}

const limiter = new Limiter(15, 10);
let throttleTime: number | null = null;
export async function classify(text: string) {
	if (isCommon(text) || !limiter.shouldRequest()) return null;
	if (throttleTime && (Date.now() - throttleTime < 10000)) {
		return null;
	}
	if (throttleTime) throttleTime = null;

	const requestData: PerspectiveRequest = {
		// todo - support 'es', 'it', 'pt', 'fr' - use user.language? room.settings.language...?
		languages: ['en'],
		requestedAttributes: ATTRIBUTES,
		comment: {text},
	};
	try {
		const raw = await Net(`https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze`).post({
			query: {
				key: Config.perspectiveKey,
			},
			body: JSON.stringify(requestData),
			headers: {
				'Content-Type': "application/json",
			},
			timeout: 10 * 1000, // 10s
		});
		if (!raw) return null;
		const data = JSON.parse(raw);
		if (data.error) throw new Error(data.message);
		const result: {[k: string]: number} = {};
		for (const k in data.attributeScores) {
			const score = data.attributeScores[k];
			result[k] = score.summaryScore.value;
		}
		return result;
	} catch (e: any) {
		throttleTime = Date.now();
		if (e.message.startsWith('Request timeout')) {
			// just ignore this. error on their end not ours.
			// todo maybe stop sending requests for a bit?
			return null;
		}
		Monitor.crashlog(e, 'A Perspective API request', {request: JSON.stringify(requestData)});
		return null;
	}
}

export const PM = new ProcessManager.QueryProcessManager<PMRequest, PMResult>(module, async query => {
	const now = Date.now();
	const result = await classify(query.comment);
	if (!result) return {score: 0, flags: []};
	const delta = Date.now() - now;
	if (delta > 1000) {
		Monitor.slow(`[Abuse Monitor] ${delta}ms - ${JSON.stringify(query)}`);
	}
	let score = 0;
	const flags = new Set<string>();
	for (const type in result) {
		const data = result[type];
		if (settings.minScore && data < settings.minScore) continue;
		const curScore = score;
		if (settings.specials[type]) {
			for (const k in settings.specials[type]) {
				if (data < Number(k)) continue;
				const num = settings.specials[type][k];
				if (num === 'MAXIMUM') {
					score = settings.threshold;
				} else {
					if (num > score) score = num;
				}
			}
		}
		if (settings.minScore) {
			// min score ensures that if a category is above that minimum score, they will get
			// at least a point.
			// we previously ensured that this was above minScore if set, so this is fine
			if (score < 1) score = 1;
		}
		if (score !== curScore) flags.add(type);
	}
	return {
		score,
		flags: [...flags],
		// undefined so that json.stringify ignores it - save bandwidth
		response: query.fullResponse ? result : undefined,
	};
}, PM_TIMEOUT, message => {
	if (message.startsWith('SLOW\n')) {
		Monitor.slow(message.slice(5));
	}
});

if (!PM.isParentProcess) {
	// This is a child process!
	global.Config = Config;
	global.Monitor = {
		crashlog(error: Error, source = 'An abuse monitor child process', details: AnyObject | null = null) {
			const repr = JSON.stringify([error.name, error.message, source, details]);
			process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
		},
		slow(text: string) {
			process.send!(`CALLBACK\nSLOW\n${text}`);
		},
	};
	global.toID = toID;
	process.on('uncaughtException', err => {
		if (Config.crashguard) {
			Monitor.crashlog(err, 'A netfilter child process');
		}
	});
	// eslint-disable-next-line no-eval
	Repl.start(`abusemonitor-${process.pid}`, cmd => eval(cmd));
} else {
	PM.spawn(Config.netfilterprocesses || 1);
}

export const chatfilter: Chat.ChatFilter = function (message, user, room) {
	if (!room?.battle?.rated || settings.disabled || cache[room.roomid]?.staffNotified) return;
	// startsWith('!') - broadcasting command, ignore it.
	if (!Config.perspectiveKey || message.startsWith('!')) return;

	const roomid = room.roomid;
	void (async () => {
		const {score, flags} = await PM.query({comment: message});
		if (score) {
			if (!cache[roomid]) cache[roomid] = {users: {}};
			if (!cache[roomid].users[user.id]) cache[roomid].users[user.id] = 0;
			cache[roomid].users[user.id] += score;
			let hitThreshold = 0;
			if (cache[roomid].users[user.id] >= settings.threshold) {
				cache[roomid].staffNotified = user.id;
				notifyStaff();
				hitThreshold = 1;
				void room?.uploadReplay?.(user, this.connection, "forpunishment");
			}
			await Chat.database.run(
				'INSERT INTO perspective_logs (userid, message, score, flags, roomid, time, hit_threshold) VALUES (?, ?, ?, ?, ?, ?, ?)',
				[user.id, message, score, Utils.sortBy(flags).join(','), roomid, Date.now(), hitThreshold]
			);
		}
	})();
};
// to avoid conflicts with other filters
chatfilter.priority = -100;

export const handlers: Chat.Handlers = {
	onRoomDestroy(roomid) {
		if (cache[roomid]) delete cache[roomid];
	},
	onRoomClose(roomid, user) {
		if (!roomid.startsWith('view-abusemonitor-view')) return;
		const targetId = roomid.slice('view-abusemonitor-view-'.length);
		if (cache[targetId].claimed === user.id) {
			delete cache[targetId].claimed;
			notifyStaff();
		}
	},
};

function getFlaggedRooms() {
	return Object.keys(cache).filter(roomid => cache[roomid].staffNotified);
}

function saveSettings() {
	FS('config/chat-plugins/nf.json').writeUpdate(() => JSON.stringify(settings));
}

{'fraction_non_alphanumeric': 0.16715469750359818, 'fraction_numerical': 0.007841580227306567, 'mean_word_length': 3.9086479902557856, 'pattern_counts': {'":': 16, '<': 25, '<?xml version=': 0, '>': 47, 'https://': 4, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5984383', 'n_tokens_mistral': 7523, 'n_tokens_neox': 6945, 'n_words': 1832}