mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-05-16 09:26:48 -05:00
* Lint arrow-body-style * Lint prefer-object-spread Object spread is faster _and_ more readable. This also fixes a few unnecessary object clones. * Enable no-parameter-properties This isn't currently used, but this makes clear that it shouldn't be. * Refactor more Promises to async/await * Remove unnecessary code from getDataMoveHTML etc * Lint prefer-string-starts-ends-with * Stop using no-undef According to the typescript-eslint FAQ, this is redundant with TypeScript, and they're not wrong. This will save us from needing to specify globals in two different places which will be nice.
716 lines
26 KiB
TypeScript
716 lines
26 KiB
TypeScript
/**
|
|
* Modlog viewer
|
|
* Pokemon Showdown - http://pokemonshowdown.com/
|
|
*
|
|
* Also handles searching battle logs.
|
|
* Actually reading, writing, and searching modlog is handled in modlog.ts.
|
|
*
|
|
* @license MIT
|
|
*/
|
|
|
|
import * as child_process from 'child_process';
|
|
import * as util from 'util';
|
|
import * as Dashycode from '../../lib/dashycode';
|
|
|
|
import {FS} from '../../lib/fs';
|
|
import {Utils} from '../../lib/utils';
|
|
import {QueryProcessManager} from '../../lib/process-manager';
|
|
import {Repl} from '../../lib/repl';
|
|
import {Dex} from '../../sim/dex';
|
|
import {Config} from '../config-loader';
|
|
import {ModlogID, ModlogSearch, ModlogEntry, checkRipgrepAvailability} from '../modlog';
|
|
|
|
interface BattleOutcome {
|
|
lost: string;
|
|
won: string;
|
|
turns: string;
|
|
}
|
|
|
|
interface BattleSearchResults {
|
|
totalBattles: number;
|
|
/** Total battle outcomes. Null when only searching for one userid.*/
|
|
totalOutcomes: BattleOutcome[] | null;
|
|
totalWins: {[k: string]: number};
|
|
totalLosses: {[k: string]: number};
|
|
totalTies: number;
|
|
timesBattled: {[k: string]: number};
|
|
}
|
|
|
|
const execFile = util.promisify(child_process.execFile);
|
|
|
|
const MAX_BATTLESEARCH_PROCESSES = 1;
|
|
const BATTLESEARCH_QUERY_TIMEOUT = 90 * 60 * 60 * 1000; // 90 minutes
|
|
const MAX_QUERY_LENGTH = 2500;
|
|
const DEFAULT_RESULTS_LENGTH = 100;
|
|
const MORE_BUTTON_INCREMENTS = [200, 400, 800, 1600, 3200];
|
|
const LINES_SEPARATOR = 'lines=';
|
|
const MAX_RESULTS_LENGTH = MORE_BUTTON_INCREMENTS[MORE_BUTTON_INCREMENTS.length - 1];
|
|
const IPS_REGEX = /[([]?([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})[)\]]?/g;
|
|
|
|
const ALIASES: {[k: string]: string} = {
|
|
'helpticket': 'help-rooms',
|
|
'groupchat': 'groupchat-rooms',
|
|
'battle': 'battle-rooms',
|
|
};
|
|
|
|
/*********************************************************
|
|
* Modlog Functions
|
|
*********************************************************/
|
|
|
|
function getMoreButton(
|
|
roomid: ModlogID, searchCmd: string,
|
|
lines: number, maxLines: number, onlyPunishments: boolean
|
|
) {
|
|
let newLines = 0;
|
|
for (const increase of MORE_BUTTON_INCREMENTS) {
|
|
if (increase > lines) {
|
|
newLines = increase;
|
|
break;
|
|
}
|
|
}
|
|
if (!newLines || lines < maxLines) {
|
|
return ''; // don't show a button if no more pre-set increments are valid or if the amount of results is already below the max
|
|
} else {
|
|
return Utils.html`<br /><div style="text-align:center"><button class="button" name="send" value="/${onlyPunishments ? 'punish' : 'mod'}log room=${roomid}, ${searchCmd}, ${LINES_SEPARATOR}${newLines}" title="View more results">Older results<br />▼</button></div>`;
|
|
}
|
|
}
|
|
|
|
function getRoomID(id: string) {
|
|
if (id in ALIASES) return ALIASES[id] as ModlogID;
|
|
return id as ModlogID;
|
|
}
|
|
|
|
function prettifyResults(
|
|
resultArray: ModlogEntry[], roomid: ModlogID, search: ModlogSearch, searchCmd: string,
|
|
addModlogLinks: boolean, hideIps: boolean, maxLines: number, onlyPunishments: boolean
|
|
) {
|
|
if (resultArray === null) {
|
|
return "|popup|The modlog query crashed.";
|
|
}
|
|
let roomName;
|
|
switch (roomid) {
|
|
case 'all':
|
|
roomName = "all rooms";
|
|
break;
|
|
case 'public':
|
|
roomName = "all public rooms";
|
|
break;
|
|
default:
|
|
roomName = `room ${roomid}`;
|
|
}
|
|
const scope = onlyPunishments ? 'punishment-related ' : '';
|
|
let searchString = ``;
|
|
if (search.anyField) searchString += `containing ${search.anyField} `;
|
|
if (search.note) searchString += `with a note including any of: ${search.note.searches.join(', ')} `;
|
|
if (search.user) searchString += `taken against ${search.user.search} `;
|
|
if (search.ip) searchString += `taken against a user on the IP ${search.ip} `;
|
|
if (search.action) searchString += `of the type ${search.action} `;
|
|
if (search.actionTaker) searchString += `taken by ${search.actionTaker} `;
|
|
if (!resultArray.length) {
|
|
return `|popup|No ${scope}moderator actions ${searchString}found on ${roomName}.`;
|
|
}
|
|
const title = `[${roomid}] ${searchCmd}`;
|
|
const lines = resultArray.length;
|
|
let curDate = '';
|
|
const resultString = resultArray.map(result => {
|
|
if (!result) return '';
|
|
const date = new Date(result.time || Date.now());
|
|
const entryRoom = result.visualRoomID || result.roomID || 'global';
|
|
let [dateString, timestamp] = Chat.toTimestamp(date, {human: true}).split(' ');
|
|
let line = `<small>[${timestamp}] (${entryRoom})</small> ${result.action}`;
|
|
if (result.userid) {
|
|
line += `: [${result.userid}]`;
|
|
if (result.autoconfirmedID) line += ` ac: [${result.autoconfirmedID}]`;
|
|
if (result.alts) line += ` alts: [${result.alts.join('], [')}]`;
|
|
if (!hideIps && result.ip) line += ` [${result.ip}]`;
|
|
}
|
|
|
|
if (result.loggedBy) line += `: by ${result.loggedBy}`;
|
|
if (result.note) line += `: ${result.note}`;
|
|
|
|
if (dateString !== curDate) {
|
|
curDate = dateString;
|
|
dateString = `</p><p>[${dateString}]<br />`;
|
|
} else {
|
|
dateString = ``;
|
|
}
|
|
const thisRoomID = entryRoom?.split(' ')[0];
|
|
if (addModlogLinks) {
|
|
const url = Config.modloglink(date, thisRoomID);
|
|
if (url) timestamp = `<a href="${url}">${timestamp}</a>`;
|
|
}
|
|
line = Utils.escapeHTML(line.slice(line.indexOf(')') + ` </small>`.length));
|
|
line = line.replace(
|
|
IPS_REGEX,
|
|
hideIps ? '' : `[<a href="https://whatismyipaddress.com/ip/$1" target="_blank">$1</a>]`
|
|
);
|
|
return `${dateString}<small>[${timestamp}] (${thisRoomID})</small>${line}`;
|
|
}).join(`<br />`);
|
|
const [dateString, timestamp] = Chat.toTimestamp(new Date(), {human: true}).split(' ');
|
|
let preamble;
|
|
const modlogid = roomid + (searchString ? '-' + Dashycode.encode(searchString) : '');
|
|
if (searchString) {
|
|
preamble = `>view-modlog-${modlogid}\n|init|html\n|title|[Modlog]${title}\n` +
|
|
`|pagehtml|<div class="pad"><p>The last ${scope}${Chat.count(lines, "logged actions")} ${Utils.escapeHTML(searchString)} on ${roomName}.`;
|
|
} else {
|
|
preamble = `>view-modlog-${modlogid}\n|init|html\n|title|[Modlog]${title}\n` +
|
|
`|pagehtml|<div class="pad"><p>The last ${Chat.count(lines, `${scope}lines`)} of the Moderator Log of ${roomName}.`;
|
|
}
|
|
preamble += `</p><p>[${dateString}]<br /><small>[${timestamp}] \u2190 current server time</small>`;
|
|
const moreButton = getMoreButton(roomid, searchCmd, lines, maxLines, onlyPunishments);
|
|
return `${preamble}${resultString}${moreButton}</div>`;
|
|
}
|
|
|
|
async function getModlog(
|
|
connection: Connection, roomid: ModlogID = 'global', search: ModlogSearch = {},
|
|
searchCmd: string, maxLines = 20, onlyPunishments = false, timed = false
|
|
) {
|
|
const targetRoom = Rooms.search(roomid);
|
|
const user = connection.user;
|
|
roomid = getRoomID(roomid);
|
|
|
|
// permission checking
|
|
if (roomid === 'all' || roomid === 'public') {
|
|
if (!user.can('modlog')) {
|
|
return connection.popup("Access denied");
|
|
}
|
|
} else {
|
|
if (!user.can('modlog', null, targetRoom) && !user.can('modlog')) {
|
|
return connection.popup("Access denied");
|
|
}
|
|
}
|
|
|
|
const hideIps = !user.can('lock');
|
|
const addModlogLinks = !!(
|
|
Config.modloglink && (user.tempGroup !== ' ' || (targetRoom && targetRoom.settings.isPrivate !== true))
|
|
);
|
|
if (hideIps && search.ip) {
|
|
connection.popup(`You cannot search for IPs.`);
|
|
return;
|
|
}
|
|
if (Object.values(search).join('').length > MAX_QUERY_LENGTH) {
|
|
connection.popup(`Your search query is too long.`);
|
|
return;
|
|
}
|
|
|
|
if (search.note?.searches) {
|
|
for (const [i, noteSearch] of search.note.searches.entries()) {
|
|
if (/^["'].+["']$/.test(noteSearch)) {
|
|
search.note.searches[i] = noteSearch.substring(1, noteSearch.length - 1);
|
|
search.note.isExact = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (search.user) {
|
|
if (/^["'].+["']$/.test(search.user.search)) {
|
|
search.user.search = search.user.search.substring(1, search.user.search.length - 1);
|
|
search.user.isExact = true;
|
|
}
|
|
search.user.search = toID(search.user.search);
|
|
}
|
|
|
|
const response = await Rooms.Modlog.search(roomid, search, maxLines, onlyPunishments);
|
|
|
|
connection.send(
|
|
prettifyResults(
|
|
response.results,
|
|
roomid,
|
|
search,
|
|
searchCmd,
|
|
addModlogLinks,
|
|
hideIps,
|
|
maxLines,
|
|
onlyPunishments
|
|
)
|
|
);
|
|
if (timed) connection.popup(`The modlog query took ${response.duration} ms to complete.`);
|
|
}
|
|
|
|
/*********************************************************
|
|
* Battle Search Functions
|
|
*********************************************************/
|
|
|
|
export async function runBattleSearch(userids: ID[], month: string, tierid: ID, turnLimit?: number) {
|
|
const useRipgrep = await checkRipgrepAvailability();
|
|
const pathString = `logs/${month}/${tierid}/`;
|
|
const results: {[k: string]: BattleSearchResults} = {};
|
|
let files = [];
|
|
try {
|
|
files = await FS(pathString).readdir();
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
return results;
|
|
}
|
|
throw err;
|
|
}
|
|
const [userid] = userids;
|
|
files = files.filter(file => file.startsWith(month)).map(file => `logs/${month}/${tierid}/${file}`);
|
|
|
|
if (useRipgrep) {
|
|
// Matches non-word (including _ which counts as a word) characters between letters/numbers
|
|
// in a user's name so the userid can case-insensitively be matched to the name.
|
|
const regexString = userids.map(id => `(.*("p(1|2)":"${[...id].join('[^a-zA-Z0-9]*')}[^a-zA-Z0-9]*"))`).join('');
|
|
let output;
|
|
try {
|
|
output = await execFile('rg', ['-i', regexString, '--no-line-number', '-tjson', ...files]);
|
|
} catch (error) {
|
|
return results;
|
|
}
|
|
for (const line of output.stdout.split('\n').reverse()) {
|
|
const [file, raw] = Utils.splitFirst(line, ':');
|
|
if (!raw || !line) continue;
|
|
const data = JSON.parse(raw);
|
|
const day = file.split('/')[3];
|
|
if (!results[day]) {
|
|
results[day] = {
|
|
totalBattles: 0,
|
|
totalWins: {},
|
|
totalOutcomes: userids.length > 1 ? [] : null,
|
|
totalLosses: {},
|
|
totalTies: 0,
|
|
timesBattled: {},
|
|
};
|
|
}
|
|
const p1id = toID(data.p1);
|
|
const p2id = toID(data.p2);
|
|
|
|
if (userids.length > 1) {
|
|
// looking for specific userids, only register ones where those users are players
|
|
if (userids.filter(item => [p1id, p2id].includes(item)).length < userids.length) continue;
|
|
} else {
|
|
if (!(p1id === userid || p2id === userid)) continue;
|
|
}
|
|
|
|
if (turnLimit && data.turns > turnLimit) continue;
|
|
if (!results[day]) {
|
|
results[day] = {
|
|
totalBattles: 0,
|
|
totalWins: {},
|
|
totalOutcomes: userids.length > 1 ? [] : null,
|
|
totalLosses: {},
|
|
totalTies: 0,
|
|
timesBattled: {},
|
|
};
|
|
}
|
|
results[day].totalBattles++;
|
|
const winnerid = toID(data.winner);
|
|
const loser = winnerid === p1id ? p2id : p1id;
|
|
if (userids.includes(winnerid)) {
|
|
if (!results[day].totalWins[winnerid]) results[day].totalWins[winnerid] = 0;
|
|
results[day].totalWins[winnerid]++;
|
|
} else if (data.winner) {
|
|
if (!results[day].totalLosses[loser]) results[day].totalLosses[loser] = 0;
|
|
results[day].totalLosses[loser]++;
|
|
} else {
|
|
results[day].totalTies++;
|
|
}
|
|
// explicitly state 0 of stats if none
|
|
for (const id of userids) {
|
|
if (!results[day].totalLosses[id]) results[day].totalLosses[id] = 0;
|
|
if (!results[day].totalWins[id]) results[day].totalWins[id] = 0;
|
|
}
|
|
|
|
const outcomes = results[day].totalOutcomes;
|
|
if (outcomes) {
|
|
outcomes.push({won: winnerid, lost: loser, turns: data.turns});
|
|
}
|
|
// we only want foe data for single-userid searches
|
|
const foe = userids.length > 1 ? null : userid === toID(data.p1) ? toID(data.p2) : toID(data.p1);
|
|
if (foe) {
|
|
if (!results[day].timesBattled[foe]) results[day].timesBattled[foe] = 0;
|
|
results[day].timesBattled[foe]++;
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
for (const file of files) {
|
|
const subFiles = FS(`${file}`).readdirSync();
|
|
const day = file.split('/')[3];
|
|
for (const dayFile of subFiles) {
|
|
const json = FS(`${file}/${dayFile}`).readIfExistsSync();
|
|
const data = JSON.parse(json);
|
|
const p1id = toID(data.p1);
|
|
const p2id = toID(data.p2);
|
|
if (userids.length > 1) {
|
|
// looking for specific userids, only register ones where those users are players
|
|
if (userids.filter(item => item === p1id || item === p2id).length < userids.length) continue;
|
|
} else {
|
|
if (!(p1id === userid || p2id === userid)) continue;
|
|
}
|
|
if (turnLimit && data.turns > turnLimit) continue;
|
|
if (!results[day]) {
|
|
results[day] = {
|
|
totalBattles: 0,
|
|
totalWins: {},
|
|
totalOutcomes: [],
|
|
totalLosses: {},
|
|
totalTies: 0,
|
|
timesBattled: {},
|
|
};
|
|
}
|
|
results[day].totalBattles++;
|
|
const winnerid = toID(data.winner);
|
|
const loser = winnerid === p1id ? p2id : p1id;
|
|
if (userids.includes(winnerid)) {
|
|
if (!results[day].totalWins[winnerid]) results[day].totalWins[winnerid] = 0;
|
|
results[day].totalWins[winnerid]++;
|
|
} else if (data.winner) {
|
|
if (!results[day].totalLosses[loser]) results[day].totalLosses[loser] = 0;
|
|
results[day].totalLosses[loser]++;
|
|
} else {
|
|
results[day].totalTies++;
|
|
}
|
|
// explicitly state 0 of stats if none
|
|
for (const id of userids) {
|
|
if (!results[day].totalLosses[id]) results[day].totalLosses[id] = 0;
|
|
if (!results[day].totalWins[id]) results[day].totalWins[id] = 0;
|
|
}
|
|
|
|
const outcomes = results[day].totalOutcomes;
|
|
if (outcomes) {
|
|
outcomes.push({won: winnerid, lost: loser, turns: data.turns});
|
|
}
|
|
|
|
// we don't want foe data if we're searching for 2 userids
|
|
const foe = userids.length > 1 ? null : userid === p1id ? p2id : p1id;
|
|
if (foe) {
|
|
if (!results[day].timesBattled[foe]) results[day].timesBattled[foe] = 0;
|
|
results[day].timesBattled[foe]++;
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function buildResults(
|
|
data: {[k: string]: BattleSearchResults}, userids: ID[],
|
|
month: string, tierid: ID, turnLimit?: number
|
|
) {
|
|
let buf = `>view-battlesearch-${userids.join('-')}--${turnLimit}--${month}--${tierid}--confirm\n|init|html\n|title|[Battle Search][${userids.join('-')}][${tierid}][${month}]\n`;
|
|
buf += `|pagehtml|<div class="pad ladder"><p>`;
|
|
buf += `${tierid} battles on ${month} where `;
|
|
buf += userids.length > 1 ? `the users ${userids.join(', ')} were players` : `the user ${userids[0]} was a player`;
|
|
buf += turnLimit ? ` and the battle lasted less than ${turnLimit} turn${Chat.plural(turnLimit)}` : '';
|
|
buf += `:</p><li style="display: inline; list-style: none"><a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}--${tierid}" target="replace">`;
|
|
buf += `<button class="button">Back</button></a></li><br />`;
|
|
if (userids.length > 1) {
|
|
const outcomes: BattleOutcome[] = [];
|
|
for (const day in data) {
|
|
const curOutcomes = data[day].totalOutcomes;
|
|
if (curOutcomes) outcomes.push(...curOutcomes);
|
|
}
|
|
buf += `<table><tbody><tr><h3 style="margin: 5px auto">Full summary</h3></tr>`;
|
|
buf += `<tr><th>Won</th><th>Lost</th><th>Turns</th></tr>`;
|
|
for (const battle of outcomes) {
|
|
const {won, lost, turns} = battle;
|
|
buf += `<tr><td>${won}</td><td>${lost}</td><td>${turns}</td></tr>`;
|
|
}
|
|
}
|
|
buf += `</tbody></table><br />`;
|
|
for (const day in data) {
|
|
const dayStats = data[day];
|
|
buf += `<p style="text-align:left">`;
|
|
const {totalWins, totalLosses} = dayStats;
|
|
buf += `<table style=""><tbody><tr><th colspan="2"><h3 style="margin: 5px auto">${day}</h3>`;
|
|
buf += `</th></tr><tr><th>Category</th><th>Number</th></tr>`;
|
|
buf += `<tr><td>Total Battles</td><td>${dayStats.totalBattles}</td></tr>`;
|
|
for (const id in totalWins) {
|
|
// hide userids if we're only searching for 1
|
|
buf += `<tr><td>Total Wins${userids.length > 1 ? ` (${id}) ` : ''}</td><td>${totalWins[id]}</td></tr>`;
|
|
}
|
|
for (const id in totalLosses) {
|
|
buf += `<tr><td>Total Losses${userids.length > 1 ? ` (${id}) ` : ''}</td><td>${totalLosses[id]}</td></tr>`;
|
|
}
|
|
if (userids.length < 2) {
|
|
buf += `<tr><th>Opponent</th><th>Times Battled</th></tr>`;
|
|
const [userid] = userids;
|
|
for (const foe in dayStats.timesBattled) {
|
|
buf += `<tr><td>`;
|
|
buf += `<a href="/view-battlesearch-${userid}-${foe}--${turnLimit}--${month}--${tierid}" target="replace">${foe}</a>`;
|
|
buf += `</td><td>${dayStats.timesBattled[foe]}</td></tr>`;
|
|
}
|
|
}
|
|
buf += `</p><br />`;
|
|
}
|
|
buf += `</tbody></table></div>`;
|
|
return buf;
|
|
}
|
|
|
|
async function getBattleSearch(
|
|
connection: Connection, userids: string[], month: string,
|
|
tierid: ID, turnLimit?: number
|
|
) {
|
|
userids = userids.map(toID);
|
|
const user = connection.user;
|
|
if (!user.can('forcewin')) return connection.popup(`/battlesearch - Access Denied`);
|
|
|
|
const response = await PM.query({userids, turnLimit, month, tierid});
|
|
connection.send(buildResults(response, userids as ID[], month, tierid, turnLimit));
|
|
}
|
|
|
|
export const pages: PageTable = {
|
|
async battlesearch(args, user, connection) {
|
|
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
|
|
this.checkCan('forcewin');
|
|
const [ids, rawLimit, month, formatid, confirmation] = Utils.splitFirst(this.pageid.slice(18), '--', 5);
|
|
let turnLimit: number | undefined = parseInt(rawLimit);
|
|
if (isNaN(turnLimit)) turnLimit = undefined;
|
|
const userids = ids.split('-');
|
|
if (!ids || turnLimit && turnLimit < 1) {
|
|
return user.popup(`Some arguments are missing or invalid for battlesearch. Use /battlesearch to start over.`);
|
|
}
|
|
this.title = `[Battle Search][${userids.join(', ')}]`;
|
|
let buf = `<div class="pad ladder"><h2>Battle Search</h2><p>Userid${Chat.plural(userids)}: ${userids.join(', ')}</p><p>`;
|
|
if (turnLimit) {
|
|
buf += `Maximum Turns: ${turnLimit}`;
|
|
}
|
|
buf += `</p>`;
|
|
|
|
const months = (await FS('logs/').readdir()).filter(f => f.length === 7 && f.includes('-')).sort((aKey, bKey) => {
|
|
const a = aKey.split('-').map(n => parseInt(n));
|
|
const b = bKey.split('-').map(n => parseInt(n));
|
|
if (a[0] !== b[0]) return b[0] - a[0];
|
|
return b[1] - a[1];
|
|
});
|
|
if (!month) {
|
|
buf += `<p>Please select a month:</p><ul style="list-style: none; display: block; padding: 0">`;
|
|
for (const i of months) {
|
|
buf += `<li style="display: inline; list-style: none"><a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${i}" target="replace"><button class="button">${i}</button></li>`;
|
|
}
|
|
return `${buf}</ul></div>`;
|
|
} else {
|
|
if (!months.includes(month)) {
|
|
return `${buf}Invalid month selected. <a href="/view-battlesearch-${userids.join('-')}--${turnLimit}" target="replace"><button class="button">Back to month selection</button></a></div>`;
|
|
}
|
|
buf += `<p><a href="/view-battlesearch-${userids.join('-')}--${turnLimit}" target="replace"><button class="button">Back</button></a> <button class="button disabled">${month}</button></p>`;
|
|
}
|
|
|
|
const tierid = toID(formatid);
|
|
const tiers = (await FS(`logs/${month}/`).readdir()).sort((a, b) => {
|
|
// First sort by gen with the latest being first
|
|
let aGen = 6;
|
|
let bGen = 6;
|
|
if (a.startsWith('gen')) aGen = parseInt(a.substring(3, 4));
|
|
if (b.startsWith('gen')) bGen = parseInt(b.substring(3, 4));
|
|
if (aGen !== bGen) return bGen - aGen;
|
|
// Sort alphabetically
|
|
const aTier = a.substring(4);
|
|
const bTier = b.substring(4);
|
|
if (aTier < bTier) return -1;
|
|
if (aTier > bTier) return 1;
|
|
return 0;
|
|
}).map(tier => {
|
|
// Use the official tier name
|
|
const format = Dex.getFormat(tier);
|
|
if (format?.exists) tier = format.name;
|
|
// Otherwise format as best as possible
|
|
if (tier.startsWith('gen')) {
|
|
return `[Gen ${tier.substring(3, 4)}] ${tier.substring(4)}`;
|
|
}
|
|
return tier;
|
|
});
|
|
if (!tierid) {
|
|
buf += `<p>Please select the tier to search:</p><ul style="list-style: none; display: block; padding: 0">`;
|
|
for (const tier of tiers) {
|
|
buf += `<li style="display: inline; list-style: none">`;
|
|
buf += `<a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}--${toID(tier)}" target="replace">`;
|
|
buf += `<button class="button">${tier}</button></a></li><br />`;
|
|
}
|
|
return `${buf}</ul></div>`;
|
|
} else {
|
|
if (!tiers.map(toID).includes(tierid)) {
|
|
return `${buf}Invalid tier selected. <a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}" target="replace"><button class="button">Back to tier selection</button></a></div>`;
|
|
}
|
|
this.title += `[${tierid}]`;
|
|
buf += `<p><a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}" target="replace"><button class="button">Back</button></a> <button class="button disabled">${tierid}</button></p>`;
|
|
}
|
|
|
|
const [userid] = userids;
|
|
if (toID(confirmation) !== 'confirm') {
|
|
buf += `<p>Are you sure you want to run a battle search for for ${tierid} battles on ${month} `;
|
|
buf += `where the ${userids.length > 1 ? `user(s) ${userids.join(', ')} were players` : `the user ${userid} was a player`}`;
|
|
if (turnLimit) buf += ` and the battle lasted less than ${turnLimit} turn${Chat.plural(turnLimit)}`;
|
|
buf += `?</p><p><a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}--${tierid}--confirm" target="replace"><button class="button notifying">Yes, run the battle search</button></a> <a href="/view-battlesearch-${userids.join('-')}--${turnLimit}--${month}--${tierid}" target="replace"><button class="button">No, go back</button></a></p>`;
|
|
return `${buf}</div>`;
|
|
}
|
|
|
|
// Run search
|
|
void getBattleSearch(connection, userids, month, tierid, turnLimit);
|
|
return (
|
|
`<div class="pad ladder"><h2>Battle Search</h2><p>` +
|
|
`Searching for ${tierid} battles on ${month} where the ` +
|
|
`${userids.length > 1 ? `user(s) ${userids.join(', ')} were players` : `the user ${userid} was a player`} ` +
|
|
(turnLimit ? `and the battle lasted less than ${turnLimit} turn${Chat.plural(turnLimit)}.` : '') +
|
|
`</p><p>Loading... (this will take a while)</p></div>`
|
|
);
|
|
},
|
|
};
|
|
|
|
export const commands: ChatCommands = {
|
|
ml: 'modlog',
|
|
punishlog: 'modlog',
|
|
pl: 'modlog',
|
|
timedmodlog: 'modlog',
|
|
modlog(target, room, user, connection, cmd) {
|
|
let roomid: ModlogID = (!room || room.roomid === 'staff' ? 'global' : room.roomid);
|
|
let lines;
|
|
const search: ModlogSearch = {};
|
|
const targets = target.split(',');
|
|
for (const [i, option] of targets.entries()) {
|
|
let [param, value] = option.split('=').map(part => part.trim());
|
|
if (!value) {
|
|
// If no specific parameter is specified, we should search all fields
|
|
value = param.trim();
|
|
if (i === 0 && targets.length > 1) {
|
|
// they might mean a roomid, as per the old format of /modlog
|
|
param = 'room';
|
|
} else {
|
|
param = 'any';
|
|
}
|
|
}
|
|
param = toID(param);
|
|
switch (param) {
|
|
case 'any':
|
|
search.anyField = value;
|
|
break;
|
|
case 'note': case 'text':
|
|
if (!search.note?.searches) search.note = {searches: []};
|
|
search.note.searches.push(value);
|
|
break;
|
|
case 'user': case 'name': case 'username': case 'userid':
|
|
search.user = {search: value};
|
|
break;
|
|
case 'ip': case 'ipaddress': case 'ipaddr':
|
|
search.ip = value;
|
|
break;
|
|
case 'action': case 'punishment':
|
|
search.action = value.toUpperCase();
|
|
break;
|
|
case 'actiontaker': case 'moderator': case 'staff': case 'mod':
|
|
search.actionTaker = toID(value);
|
|
break;
|
|
case 'room': case 'roomid':
|
|
roomid = value.toLowerCase().replace(/[^a-z0-9-]+/g, '') as ModlogID;
|
|
break;
|
|
case 'lines': case 'maxlines':
|
|
lines = parseInt(value);
|
|
if (isNaN(lines) || lines < 1) return this.errorReply(`Invalid linecount: '${value}'.`);
|
|
break;
|
|
default:
|
|
this.errorReply(`Invalid modlog parameter: '${param}'.`);
|
|
return this.errorReply(`Please specify 'room', 'note', 'user', 'ip', 'action', 'staff', 'any', or 'lines'.`);
|
|
}
|
|
}
|
|
|
|
const targetRoom = Rooms.search(roomid);
|
|
// if a room alias was used, replace alias with actual id
|
|
if (targetRoom) roomid = targetRoom.roomid;
|
|
|
|
if (roomid.includes('-')) {
|
|
if (user.can('modlog')) {
|
|
// default to global modlog for staff convenience
|
|
roomid = 'global';
|
|
} else {
|
|
return this.errorReply(`Only global staff may view battle and groupchat modlogs.`);
|
|
}
|
|
}
|
|
|
|
if (!target && !lines) {
|
|
lines = 20;
|
|
}
|
|
if (!lines) lines = DEFAULT_RESULTS_LENGTH;
|
|
if (lines > MAX_RESULTS_LENGTH) lines = MAX_RESULTS_LENGTH;
|
|
|
|
void getModlog(
|
|
connection,
|
|
roomid,
|
|
search,
|
|
target.replace(/^\s?([^,=]*),\s?/, '').replace(/,?\s*(room|lines)\s*=[^,]*,?/g, ''),
|
|
lines,
|
|
(cmd === 'punishlog' || cmd === 'pl'),
|
|
cmd === 'timedmodlog'
|
|
);
|
|
},
|
|
modloghelp() {
|
|
this.sendReplyBox(
|
|
`<code>/modlog [comma-separated list of parameters]</code>: searches the moderator log, defaulting to the current room unless specified otherwise.<br />` +
|
|
`If an unnamed parameter is specified, <code>/modlog</code> will search all fields at once.<br />` +
|
|
`<details><summary>Parameters:</summary>` +
|
|
`<ul>` +
|
|
`<li><code>room=[room]</code> - searches a room's modlog</li>` +
|
|
`<li><code>any=[text]</code> - searches for modlog entries containing the specified text in any field</li>` +
|
|
`<li><code>userid=[user]</code> - searches for a username (or fragment of one)</li>` +
|
|
`<li><code>note=[text]</code> - searches the contents of notes/reasons</li>` +
|
|
`<li><code>ip=[IP address]</code> - searches for an IP address (or fragment of one)</li>` +
|
|
`<li><code>staff=[user]</code> - searches for actions taken by a particular staff member</li>` +
|
|
`<li><code>action=[type]</code> - searches for a particular type of action</li>` +
|
|
`<li><code>lines=[number]</code> - displays the given number of lines</li>` +
|
|
`</ul>` +
|
|
`</details>`
|
|
);
|
|
},
|
|
|
|
battlesearch(target, room, user, connection) {
|
|
if (!target.trim()) return this.parse('/help battlesearch');
|
|
this.checkCan('forcewin');
|
|
|
|
const parts = target.split(',');
|
|
let turnLimit;
|
|
const ids = [];
|
|
for (const part of parts) {
|
|
const parsed = parseInt(part);
|
|
if (!isNaN(parsed)) turnLimit = parsed;
|
|
else ids.push(part);
|
|
}
|
|
// Selection on month, tier, and date will be handled in the HTML room
|
|
return this.parse(`/join view-battlesearch-${ids.map(toID).join('-')}--${turnLimit || ""}`);
|
|
},
|
|
battlesearchhelp: [
|
|
'/battlesearch [args] - Searches rated battle history for the provided [args] and returns information on battles between the userids given.',
|
|
`If a number is provided in the [args], it is assumed to be a turn limit; otherwise, they're assumed to be userids. Requires &`,
|
|
],
|
|
};
|
|
|
|
/*********************************************************
|
|
* Process manager
|
|
*********************************************************/
|
|
|
|
export const PM = new QueryProcessManager<AnyObject, AnyObject>(module, async data => {
|
|
const {userids, turnLimit, month, tierid} = data;
|
|
try {
|
|
return await runBattleSearch(userids, month, tierid, turnLimit);
|
|
} catch (err) {
|
|
Monitor.crashlog(err, 'A battle search query', {
|
|
userids,
|
|
turnLimit,
|
|
month,
|
|
tierid,
|
|
});
|
|
}
|
|
return null;
|
|
}, BATTLESEARCH_QUERY_TIMEOUT);
|
|
|
|
if (!PM.isParentProcess) {
|
|
// This is a child process!
|
|
global.Config = Config;
|
|
global.Monitor = {
|
|
crashlog(error: Error, source = 'A battle search process', details: AnyObject | null = null) {
|
|
const repr = JSON.stringify([error.name, error.message, source, details]);
|
|
// @ts-ignore
|
|
process.send(`THROW\n@!!@${repr}\n${error.stack}`);
|
|
},
|
|
};
|
|
process.on('uncaughtException', err => {
|
|
if (Config.crashguard) {
|
|
Monitor.crashlog(err, 'A battle search child process');
|
|
}
|
|
});
|
|
global.Dex = Dex;
|
|
global.toID = Dex.toID;
|
|
// eslint-disable-next-line no-eval
|
|
Repl.start('battlesearch', cmd => eval(cmd));
|
|
} else {
|
|
PM.spawn(MAX_BATTLESEARCH_PROCESSES);
|
|
}
|