mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-05-23 08:16:16 -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.
854 lines
29 KiB
TypeScript
854 lines
29 KiB
TypeScript
/**
|
|
* Pokemon Showdown log viewer
|
|
*
|
|
* by Zarel
|
|
* @license MIT
|
|
*/
|
|
|
|
import {FS} from "../../lib/fs";
|
|
import {Utils} from '../../lib/utils';
|
|
import * as child_process from 'child_process';
|
|
import * as util from 'util';
|
|
import * as path from 'path';
|
|
import * as Dashycode from '../../lib/dashycode';
|
|
import {QueryProcessManager} from "../../lib/process-manager";
|
|
import {Repl} from '../../lib/repl';
|
|
import {Config} from '../config-loader';
|
|
import {Dex} from '../../sim/dex';
|
|
import {Chat} from '../chat';
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
const MAX_RESULTS = 3000;
|
|
const MAX_MEMORY = 67108864; // 64MB
|
|
const MAX_PROCESSES = 1;
|
|
const execFile = util.promisify(child_process.execFile);
|
|
|
|
export class LogReaderRoom {
|
|
roomid: RoomID;
|
|
constructor(roomid: RoomID) {
|
|
this.roomid = roomid;
|
|
}
|
|
|
|
async listMonths() {
|
|
try {
|
|
const listing = await FS(`logs/chat/${this.roomid}`).readdir();
|
|
return listing.filter(file => /^[0-9][0-9][0-9][0-9]-[0-9][0-9]$/.test(file));
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async listDays(month: string) {
|
|
try {
|
|
const listing = await FS(`logs/chat/${this.roomid}/${month}`).readdir();
|
|
return listing.filter(file => file.endsWith(".txt")).map(file => file.slice(0, -4));
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getLog(day: string) {
|
|
const month = LogReader.getMonth(day);
|
|
const log = FS(`logs/chat/${this.roomid}/${month}/${day}.txt`);
|
|
if (!await log.exists()) return null;
|
|
return log.createReadStream();
|
|
}
|
|
}
|
|
|
|
const LogReader = new class {
|
|
async get(roomid: RoomID) {
|
|
if (!await FS(`logs/chat/${roomid}`).exists()) return null;
|
|
return new LogReaderRoom(roomid);
|
|
}
|
|
|
|
async list() {
|
|
const listing = await FS(`logs/chat`).readdir();
|
|
return listing.filter(file => /^[a-z0-9-]+$/.test(file)) as RoomID[];
|
|
}
|
|
|
|
async listCategorized(user: User, opts?: string) {
|
|
const list = await this.list();
|
|
const isUpperStaff = user.can('rangeban');
|
|
const isStaff = user.can('lock');
|
|
|
|
const official = [];
|
|
const normal = [];
|
|
const hidden = [];
|
|
const secret = [];
|
|
const deleted = [];
|
|
const personal: RoomID[] = [];
|
|
const deletedPersonal: RoomID[] = [];
|
|
let atLeastOne = false;
|
|
|
|
for (const roomid of list) {
|
|
const room = Rooms.get(roomid);
|
|
const forceShow = room && (
|
|
// you are authed in the room
|
|
(room.auth.has(user.id) && user.can('mute', null, room)) ||
|
|
// you are staff and currently in the room
|
|
(isStaff && user.inRooms.has(room.roomid))
|
|
);
|
|
if (!isUpperStaff && !forceShow) {
|
|
if (!isStaff) continue;
|
|
if (!room) continue;
|
|
if (!room.checkModjoin(user)) continue;
|
|
if (room.settings.isPrivate === true) continue;
|
|
}
|
|
|
|
atLeastOne = true;
|
|
if (roomid.includes('-')) {
|
|
const matchesOpts = opts && roomid.startsWith(`${opts}-`);
|
|
if (matchesOpts || opts === 'all' || forceShow) {
|
|
(room ? personal : deletedPersonal).push(roomid);
|
|
}
|
|
} else if (!room) {
|
|
if (opts === 'all' || opts === 'deleted') deleted.push(roomid);
|
|
} else if (room.settings.isOfficial) {
|
|
official.push(roomid);
|
|
} else if (!room.settings.isPrivate) {
|
|
normal.push(roomid);
|
|
} else if (room.settings.isPrivate === 'hidden') {
|
|
hidden.push(roomid);
|
|
} else {
|
|
secret.push(roomid);
|
|
}
|
|
}
|
|
|
|
if (!atLeastOne) return null;
|
|
return {official, normal, hidden, secret, deleted, personal, deletedPersonal};
|
|
}
|
|
|
|
async read(roomid: RoomID, day: string, limit: number) {
|
|
const roomLog = await LogReader.get(roomid);
|
|
const stream = await roomLog!.getLog(day);
|
|
let buf = '';
|
|
let i = LogViewer.results || 0;
|
|
if (!stream) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't have logs for ${day}</p>`;
|
|
} else {
|
|
for await (const line of stream.byLine()) {
|
|
const rendered = LogViewer.renderLine(line);
|
|
if (rendered) {
|
|
buf += `${line}\n`;
|
|
i++;
|
|
if (i > limit) break;
|
|
}
|
|
}
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
getMonth(day: string) {
|
|
return day.slice(0, 7);
|
|
}
|
|
nextDay(day: string) {
|
|
const nextDay = new Date(new Date(day).getTime() + DAY);
|
|
return nextDay.toISOString().slice(0, 10);
|
|
}
|
|
prevDay(day: string) {
|
|
const prevDay = new Date(new Date(day).getTime() - DAY);
|
|
return prevDay.toISOString().slice(0, 10);
|
|
}
|
|
nextMonth(month: string) {
|
|
const nextMonth = new Date(new Date(`${month}-15`).getTime() + 30 * DAY);
|
|
return nextMonth.toISOString().slice(0, 7);
|
|
}
|
|
prevMonth(month: string) {
|
|
const prevMonth = new Date(new Date(`${month}-15`).getTime() - 30 * DAY);
|
|
return prevMonth.toISOString().slice(0, 7);
|
|
}
|
|
|
|
today() {
|
|
return Chat.toTimestamp(new Date()).slice(0, 10);
|
|
}
|
|
};
|
|
|
|
export const LogViewer = new class {
|
|
results: number;
|
|
constructor() {
|
|
this.results = 0;
|
|
}
|
|
async day(roomid: RoomID, day: string, opts?: string) {
|
|
const month = LogReader.getMonth(day);
|
|
let buf = `<div class="pad"><p>` +
|
|
`<a roomid="view-chatlog">◂ All logs</a> / ` +
|
|
`<a roomid="view-chatlog-${roomid}">${roomid}</a> / ` +
|
|
`<a roomid="view-chatlog-${roomid}--${month}">${month}</a> / ` +
|
|
`<strong>${day}</strong></p><small>${opts ? `Options in use: ${opts}` : ''}</small> <hr />`;
|
|
|
|
const roomLog = await LogReader.get(roomid);
|
|
if (!roomLog) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't exist</p></div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
|
|
const prevDay = LogReader.prevDay(day);
|
|
buf += `<p><a roomid="view-chatlog-${roomid}--${prevDay}" class="blocklink" style="text-align:center">▲<br />${prevDay}</a></p>` +
|
|
`<div class="message-log" style="overflow-wrap: break-word">`;
|
|
|
|
const stream = await roomLog.getLog(day);
|
|
if (!stream) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't have logs for ${day}</p>`;
|
|
} else {
|
|
for await (const line of stream.byLine()) {
|
|
buf += this.renderLine(line, opts);
|
|
}
|
|
}
|
|
buf += `</div>`;
|
|
if (day !== LogReader.today()) {
|
|
const nextDay = LogReader.nextDay(day);
|
|
buf += `<p><a roomid="view-chatlog-${roomid}--${nextDay}" class="blocklink" style="text-align:center">${nextDay}<br />▼</a></p>`;
|
|
}
|
|
|
|
buf += `</div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
|
|
renderDayResults(results: {[day: string]: SearchMatch[]}, roomid: RoomID) {
|
|
const renderResult = (match: SearchMatch) => {
|
|
this.results++;
|
|
return (
|
|
this.renderLine(match[0]) +
|
|
this.renderLine(match[1]) +
|
|
`<div class="chat chatmessage highlighted">${this.renderLine(match[2])}</div>` +
|
|
this.renderLine(match[3]) +
|
|
this.renderLine(match[4])
|
|
);
|
|
};
|
|
|
|
let buf = ``;
|
|
for (const day in results) {
|
|
const dayResults = results[day];
|
|
const plural = dayResults.length !== 1 ? "es" : "";
|
|
buf += `<details><summary>${dayResults.length} match${plural} on `;
|
|
buf += `<a href="view-chatlog-${roomid}--${day}">${day}</a></summary><br /><hr />`;
|
|
buf += `<p>${dayResults.filter(Boolean).map(result => renderResult(result)).join(`<hr />`)}</p>`;
|
|
buf += `</details><hr />`;
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
async searchMonth(roomid: RoomID, month: string, search: string, limit: number, year = false) {
|
|
const {results, total} = await LogSearcher.fsSearchMonth(roomid, month, search, limit);
|
|
if (!total) {
|
|
return LogViewer.error(`No matches found for ${search} on ${roomid}.`);
|
|
}
|
|
|
|
let buf = (
|
|
`<br /><div class="pad"><strong>Searching for "${search}" in ${roomid} (${month}):</strong><hr />`
|
|
);
|
|
buf += this.renderDayResults(results, roomid);
|
|
if (total > limit) {
|
|
// cap is met & is not being used in a year read
|
|
buf += `<br /><strong>Max results reached, capped at ${limit}</strong>`;
|
|
buf += `<br /><div style="text-align:center">`;
|
|
if (total < MAX_RESULTS) {
|
|
buf += `<button class="button" name="send" value="/sl ${search},room:${roomid},date:${month},limit:${limit + 100}">View 100 more<br />▼</button>`;
|
|
buf += `<button class="button" name="send" value="/sl ${search},room:${roomid},date:${month},limit:3000">View all<br />▼</button></div>`;
|
|
}
|
|
}
|
|
buf += `</div>`;
|
|
this.results = 0;
|
|
return buf;
|
|
}
|
|
|
|
async searchYear(roomid: RoomID, year: string | null, search: string, limit: number) {
|
|
const {results, total} = await LogSearcher.fsSearchYear(roomid, year, search, limit);
|
|
if (!total) {
|
|
return LogViewer.error(`No matches found for ${search} on ${roomid}.`);
|
|
}
|
|
let buf = '';
|
|
if (year) {
|
|
buf += `<div class="pad"><strong><br />Searching year: ${year}: </strong><hr />`;
|
|
} else {
|
|
buf += `<div class="pad"><strong><br />Searching all logs: </strong><hr />`;
|
|
}
|
|
buf += this.renderDayResults(results, roomid);
|
|
if (total > limit) {
|
|
// cap is met
|
|
buf += `<br /><strong>Max results reached, capped at ${total > limit ? limit : MAX_RESULTS}</strong>`;
|
|
buf += `<br /><div style="text-align:center">`;
|
|
if (total < MAX_RESULTS) {
|
|
buf += `<button class="button" name="send" value="/sl ${search}|${roomid}|${year}|${limit + 100}">View 100 more<br />▼</button>`;
|
|
buf += `<button class="button" name="send" value="/sl ${search}|${roomid}|${year}|all">View all<br />▼</button></div>`;
|
|
}
|
|
}
|
|
this.results = 0;
|
|
return buf;
|
|
}
|
|
|
|
renderLine(fullLine: string, opts?: string) {
|
|
if (!fullLine) return ``;
|
|
if (opts === 'txt') return `<div class="chat">${fullLine}</div>`;
|
|
let timestamp = fullLine.slice(0, opts ? 8 : 5);
|
|
let line;
|
|
if (/^[0-9:]+$/.test(timestamp)) {
|
|
line = fullLine.charAt(9) === '|' ? fullLine.slice(10) : '|' + fullLine.slice(9);
|
|
} else {
|
|
timestamp = '';
|
|
line = '!NT|';
|
|
}
|
|
if (opts !== 'all' && (
|
|
line.startsWith(`userstats|`) ||
|
|
line.startsWith('J|') || line.startsWith('L|') || line.startsWith('N|')
|
|
)) return ``;
|
|
|
|
const cmd = line.slice(0, line.indexOf('|'));
|
|
if (opts?.includes('onlychat')) {
|
|
if (cmd !== 'c') return '';
|
|
if (opts.includes('txt')) return `<div class="chat">${fullLine}</div>`;
|
|
}
|
|
switch (cmd) {
|
|
case 'c': {
|
|
const [, name, message] = Utils.splitFirst(line, '|', 2);
|
|
if (name.length <= 1) {
|
|
return `<div class="chat"><small>[${timestamp}] </small><q>${Chat.formatText(message)}</q></div>`;
|
|
}
|
|
if (message.startsWith(`/log `)) {
|
|
return `<div class="chat"><small>[${timestamp}] </small><q>${Chat.formatText(message.slice(5))}</q></div>`;
|
|
}
|
|
if (message.startsWith(`/raw `)) {
|
|
return `<div class="notice">${message.slice(5)}</div>`;
|
|
}
|
|
if (message.startsWith(`/uhtml `) || message.startsWith(`/uhtmlchange `)) {
|
|
if (message.startsWith(`/uhtmlchange `)) return ``;
|
|
if (opts !== 'all') return `<div class="notice">[uhtml box hidden]</div>`;
|
|
return `<div class="notice">${message.slice(message.indexOf(',') + 1)}</div>`;
|
|
}
|
|
const group = !name.startsWith(' ') ? `<small>${name.charAt(0)}</small>` : ``;
|
|
return `<div class="chat"><small>[${timestamp}] </small><strong>${group}${Utils.escapeHTML(name.slice(1))}:</strong> <q>${Chat.formatText(message)}</q></div>`;
|
|
}
|
|
case 'html': case 'raw': {
|
|
const [, html] = Utils.splitFirst(line, '|', 1);
|
|
return `<div class="notice">${html}</div>`;
|
|
}
|
|
case 'uhtml': case 'uhtmlchange': {
|
|
if (cmd !== 'uhtml') return ``;
|
|
const [, , html] = Utils.splitFirst(line, '|', 2);
|
|
return `<div class="notice">${html}</div>`;
|
|
}
|
|
case '!NT':
|
|
return `<div class="chat">${Utils.escapeHTML(fullLine)}</div>`;
|
|
case '':
|
|
return `<div class="chat"><small>[${timestamp}] </small>${Utils.escapeHTML(line.slice(1))}</div>`;
|
|
default:
|
|
return `<div class="chat"><small>[${timestamp}] </small><code>${'|' + Utils.escapeHTML(line)}</code></div>`;
|
|
}
|
|
}
|
|
|
|
async month(roomid: RoomID, month: string) {
|
|
let buf = `<div class="pad"><p>` +
|
|
`<a roomid="view-chatlog">◂ All logs</a> / ` +
|
|
`<a roomid="view-chatlog-${roomid}">${roomid}</a> / ` +
|
|
`<strong>${month}</strong></p><hr />`;
|
|
|
|
const roomLog = await LogReader.get(roomid);
|
|
if (!roomLog) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't exist</p></div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
|
|
const prevMonth = LogReader.prevMonth(month);
|
|
buf += `<p><a roomid="view-chatlog-${roomid}--${prevMonth}" class="blocklink" style="text-align:center">▲<br />${prevMonth}</a></p><div>`;
|
|
|
|
const days = await roomLog.listDays(month);
|
|
if (!days.length) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't have logs in ${month}</p></div>`;
|
|
return this.linkify(buf);
|
|
} else {
|
|
for (const day of days) {
|
|
buf += `<p>- <a roomid="view-chatlog-${roomid}--${day}">${day}</a> <small>`;
|
|
for (const opt of ['txt', 'onlychat', 'all', 'txt-onlychat']) {
|
|
buf += ` (<a roomid="view-chatlog-${roomid}--${day}--${opt}">${opt}</a>) `;
|
|
}
|
|
buf += `</small></p>`;
|
|
}
|
|
}
|
|
|
|
if (!LogReader.today().startsWith(month)) {
|
|
const nextMonth = LogReader.nextMonth(month);
|
|
buf += `<p><a roomid="view-chatlog-${roomid}--${nextMonth}" class="blocklink" style="text-align:center">${nextMonth}<br />▼</a></p>`;
|
|
}
|
|
|
|
buf += `</div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
async room(roomid: RoomID) {
|
|
let buf = `<div class="pad"><p>` +
|
|
`<a roomid="view-chatlog">◂ All logs</a> / ` +
|
|
`<strong>${roomid}</strong></p><hr />`;
|
|
|
|
const roomLog = await LogReader.get(roomid);
|
|
if (!roomLog) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't exist</p></div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
|
|
const months = await roomLog.listMonths();
|
|
if (!months.length) {
|
|
buf += `<p class="message-error">Room "${roomid}" doesn't have logs</p></div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
|
|
for (const month of months) {
|
|
buf += `<p>- <a roomid="view-chatlog-${roomid}--${month}">${month}</a></p>`;
|
|
}
|
|
buf += `</div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
async list(user: User, opts?: string) {
|
|
let buf = `<div class="pad"><p>` +
|
|
`<strong>All logs</strong></p><hr />`;
|
|
|
|
const categories: {[k: string]: string} = {
|
|
'official': "Official",
|
|
'normal': "Public",
|
|
'hidden': "Hidden",
|
|
'secret': "Secret",
|
|
'deleted': "Deleted",
|
|
'personal': "Personal",
|
|
'deletedPersonal': "Deleted Personal",
|
|
};
|
|
const list = await LogReader.listCategorized(user, opts) as {[k: string]: RoomID[]};
|
|
|
|
if (!list) {
|
|
buf += `<p class="message-error">You must be a staff member of a room to view its logs</p></div>`;
|
|
return buf;
|
|
}
|
|
|
|
const showPersonalLink = opts !== 'all' && user.can('rangeban');
|
|
for (const k in categories) {
|
|
if (!list[k].length && !(['personal', 'deleted'].includes(k) && showPersonalLink)) {
|
|
continue;
|
|
}
|
|
buf += `<p>${categories[k]}</p>`;
|
|
if (k === 'personal' && showPersonalLink) {
|
|
if (opts !== 'help') buf += `<p>- <a roomid="view-chatlog--help">(show all help)</a></p>`;
|
|
if (opts !== 'groupchat') buf += `<p>- <a roomid="view-chatlog--groupchat">(show all groupchat)</a></p>`;
|
|
}
|
|
if (k === 'deleted' && showPersonalLink) {
|
|
if (opts !== 'deleted') buf += `<p>- <a roomid="view-chatlog--deleted">(show deleted)</a></p>`;
|
|
}
|
|
for (const roomid of list[k]) {
|
|
buf += `<p>- <a roomid="view-chatlog-${roomid}">${roomid}</a></p>`;
|
|
}
|
|
}
|
|
buf += `</div>`;
|
|
return this.linkify(buf);
|
|
}
|
|
error(message: string) {
|
|
return `<div class="pad"><p class="message-error">${message}</p></div>`;
|
|
}
|
|
linkify(buf: string) {
|
|
return buf.replace(/<a roomid="/g, `<a target="replace" href="/`);
|
|
}
|
|
};
|
|
|
|
/** Match with two lines of context in either direction */
|
|
type SearchMatch = readonly [string, string, string, string, string];
|
|
|
|
export const LogSearcher = new class {
|
|
async runSearch(
|
|
context: PageContext, search: string, roomid: RoomID, date: string | null, limit: number | null
|
|
) {
|
|
context.title = `[Search] [${roomid}] ${search}`;
|
|
if (!['ripgrep', 'fs'].includes(Config.chatlogreader)) {
|
|
throw new Error(`Config.chatlogreader must be 'fs' or 'ripgrep'.`);
|
|
}
|
|
context.send(
|
|
`<div class="pad"><h2>Running a chatlog search for "${search}" on room ${roomid}` +
|
|
(date ? date !== 'all' ? `, on the date "${date}"` : ', on all dates' : '') +
|
|
`.</h2></div>`
|
|
);
|
|
const response = await PM.query({search, roomid, date, limit});
|
|
return context.send(response);
|
|
}
|
|
constructRegex(str: string) {
|
|
// modified regex replace
|
|
str = str.replace(/[\\^$.*?()[\]{}|]/g, '\\$&');
|
|
const searches = str.split('+');
|
|
if (searches.length <= 1) {
|
|
if (str.length <= 3) return `\b${str}`;
|
|
return str;
|
|
}
|
|
|
|
return `^` + searches.map(term => `(?=.*${term})`).join('');
|
|
}
|
|
|
|
fsSearch(roomid: RoomID, search: string, date: string, limit: number | null) {
|
|
const isAll = (date === 'all');
|
|
const isYear = (date.length === 4);
|
|
const isMonth = (date.length === 7);
|
|
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
if (isAll) {
|
|
return LogViewer.searchYear(roomid, null, search, limit);
|
|
} else if (isYear) {
|
|
date = date.substr(0, 4);
|
|
return LogViewer.searchYear(roomid, date, search, limit);
|
|
} else if (isMonth) {
|
|
date = date.substr(0, 7);
|
|
return LogViewer.searchMonth(roomid, date, search, limit);
|
|
} else {
|
|
return LogViewer.error("Invalid date.");
|
|
}
|
|
}
|
|
|
|
async fsSearchDay(roomid: RoomID, day: string, search: string, limit?: number | null) {
|
|
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
const text = await LogReader.read(roomid, day, limit);
|
|
if (!text) return [];
|
|
const lines = text.split('\n');
|
|
const matches: SearchMatch[] = [];
|
|
|
|
const searchTerms = search.split('+');
|
|
const searchTermRegexes = searchTerms.map(term => new RegExp(term, 'i'));
|
|
function matchLine(line: string) {
|
|
return searchTermRegexes.every(term => term.test(line));
|
|
}
|
|
|
|
for (const [i, line] of lines.entries()) {
|
|
if (matchLine(line)) {
|
|
matches.push([
|
|
lines[i - 2],
|
|
lines[i - 1],
|
|
line,
|
|
lines[i + 1],
|
|
lines[i + 2],
|
|
]);
|
|
if (matches.length > limit) break;
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
async fsSearchMonth(roomid: RoomID, month: string, search: string, limit: number) {
|
|
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
const log = await LogReader.get(roomid);
|
|
if (!log) return {results: {}, total: 0};
|
|
const days = await log.listDays(month);
|
|
const results: {[k: string]: SearchMatch[]} = {};
|
|
let total = 0;
|
|
|
|
for (const day of days) {
|
|
const dayResults = await this.fsSearchDay(roomid, day, search, limit ? limit - total : null);
|
|
if (!dayResults.length) continue;
|
|
total += dayResults.length;
|
|
results[day] = dayResults;
|
|
if (total > limit) break;
|
|
}
|
|
return {results, total};
|
|
}
|
|
|
|
/** pass a null `year` to search all-time */
|
|
async fsSearchYear(roomid: RoomID, year: string | null, search: string, limit?: number | null) {
|
|
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
const log = await LogReader.get(roomid);
|
|
if (!log) return {results: {}, total: 0};
|
|
let months = await log.listMonths();
|
|
months = months.reverse();
|
|
const results: {[k: string]: SearchMatch[]} = {};
|
|
let total = 0;
|
|
|
|
for (const month of months) {
|
|
if (year && !month.includes(year)) continue;
|
|
const monthSearch = await this.fsSearchMonth(roomid, month, search, limit);
|
|
const {results: monthResults, total: monthTotal} = monthSearch;
|
|
if (!monthTotal) continue;
|
|
total += monthTotal;
|
|
Object.assign(results, monthResults);
|
|
if (total > limit) break;
|
|
}
|
|
return {results, total};
|
|
}
|
|
async ripgrepSearchMonth(roomid: RoomID, search: string, limit: number, month: string) {
|
|
let results;
|
|
let count = 0;
|
|
try {
|
|
const {stdout} = await execFile('rg', [
|
|
'-e', this.constructRegex(search),
|
|
`logs/chat/${roomid}/${month}`,
|
|
'-C', '3',
|
|
'-m', `${limit}`,
|
|
'-P',
|
|
], {
|
|
maxBuffer: MAX_MEMORY,
|
|
cwd: path.normalize(`${__dirname}/../../`),
|
|
});
|
|
results = stdout.split('--');
|
|
} catch (e) {
|
|
if (e.message.includes('No such file or directory')) {
|
|
throw new Chat.ErrorMessage(`Logs for date '${month}' do not exist.`);
|
|
}
|
|
if (e.code !== 1 && !e.message.includes('stdout maxBuffer')) throw e; // 2 means an error in ripgrep
|
|
if (e.stdout) {
|
|
results = e.stdout.split('--');
|
|
} else {
|
|
results = [];
|
|
}
|
|
}
|
|
count += results.length;
|
|
return {results, count};
|
|
}
|
|
async ripgrepSearch(
|
|
roomid: RoomID,
|
|
search: string,
|
|
limit?: number | null,
|
|
date?: string | null
|
|
) {
|
|
if (date) {
|
|
// if it's more than 7 chars, assume it's a month
|
|
if (date.length > 7) date = date.substr(0, 7);
|
|
// if it's less, assume they were trying a year
|
|
else if (date.length < 7) date = date.substr(0, 4);
|
|
}
|
|
const months = (date && toID(date) !== 'all' ? [date] : await new LogReaderRoom(roomid).listMonths()).reverse();
|
|
let count = 0;
|
|
let results: string[] = [];
|
|
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
if (!date) date = 'all';
|
|
while (count < MAX_RESULTS) {
|
|
const month = months.shift();
|
|
if (!month) break;
|
|
const output = await this.ripgrepSearchMonth(roomid, search, limit, month);
|
|
results = results.concat(output.results);
|
|
count += output.count;
|
|
}
|
|
if (count > MAX_RESULTS) {
|
|
const diff = count - MAX_RESULTS;
|
|
results = results.slice(0, -diff);
|
|
}
|
|
return this.renderResults(results, roomid, search, limit, date);
|
|
}
|
|
|
|
renderResults(results: string[], roomid: RoomID, search: string, limit: number, month?: string | null) {
|
|
results = results.filter(Boolean);
|
|
if (results.length < 1) return LogViewer.error('No results found.');
|
|
let exactMatches = 0;
|
|
let curDate = '';
|
|
if (limit > MAX_RESULTS) limit = MAX_RESULTS;
|
|
const searchRegex = new RegExp(this.constructRegex(search), "i");
|
|
const sorted = results.sort((aLine, bLine) => {
|
|
const [aName] = aLine.split('.txt');
|
|
const [bName] = bLine.split('.txt');
|
|
const aDate = new Date(aName.split('/').pop()!);
|
|
const bDate = new Date(bName.split('/').pop()!);
|
|
return bDate.getTime() - aDate.getTime();
|
|
}).map(chunk => chunk.split('\n').map(line => {
|
|
if (exactMatches > limit || !toID(line)) return null; // return early so we don't keep sorting
|
|
const sep = line.includes('.txt-') ? '.txt-' : '.txt:';
|
|
const [name, text] = line.split(sep);
|
|
line = LogViewer.renderLine(text, 'all');
|
|
if (!line || name.includes('today')) return null;
|
|
// gets rid of some edge cases / duplicates
|
|
let date = name.replace(`logs/chat/${roomid}${toID(month) === 'all' ? '' : `/${month}`}`, '').slice(9);
|
|
if (searchRegex.test(line)) {
|
|
if (++exactMatches > limit) return null;
|
|
line = `<div class="chat chatmessage highlighted">${line}</div>`;
|
|
}
|
|
if (curDate !== date) {
|
|
curDate = date;
|
|
date = `</div></details><details open><summary>[<a href="view-chatlog-${roomid}--${date}">${date}</a>]</summary>`;
|
|
} else {
|
|
date = '';
|
|
}
|
|
return `${date} ${line}`;
|
|
}).filter(Boolean).join(' ')).filter(Boolean);
|
|
let buf = `<div class ="pad"><strong>Results on ${roomid} for ${search}:</strong>`;
|
|
buf += limit ? ` ${exactMatches} (capped at ${limit})` : '';
|
|
buf += `<hr /></div><blockquote>`;
|
|
buf += sorted.join('<hr />');
|
|
if (limit) {
|
|
buf += `</details></blockquote><div class="pad"><hr /><strong>Capped at ${limit}.</strong><br />`;
|
|
buf += `<button class="button" name="send" value="/sl ${search},room:${roomid},limit:${limit + 200}">View 200 more<br />▼</button>`;
|
|
buf += `<button class="button" name="send" value="/sl ${search},room:${roomid},limit:3000">View all<br />▼</button></div>`;
|
|
}
|
|
return buf;
|
|
}
|
|
};
|
|
|
|
|
|
export const PM = new QueryProcessManager<AnyObject, string | undefined>(module, async data => {
|
|
try {
|
|
const {date, search, roomid, limit} = data;
|
|
switch (Config.chatlogreader) {
|
|
case 'fs':
|
|
return await LogSearcher.fsSearch(roomid, search, date, limit);
|
|
case 'ripgrep':
|
|
return await LogSearcher.ripgrepSearch(roomid, search, limit, date);
|
|
default:
|
|
return LogViewer.error(`Config.chatlogreader is not configured.`);
|
|
}
|
|
} catch (e) {
|
|
if (e.name?.endsWith('ErrorMessage')) {
|
|
return LogViewer.error(e.message);
|
|
}
|
|
Monitor.crashlog(e, 'A chatlog search query', data);
|
|
return LogViewer.error(`Sorry! Your chatlog search crashed. We've been notified and will fix this.`);
|
|
}
|
|
});
|
|
|
|
if (!PM.isParentProcess) {
|
|
// This is a child process!
|
|
global.Config = Config;
|
|
global.Monitor = {
|
|
crashlog(error: Error, source = 'A chatlog search process', details: AnyObject | null = null) {
|
|
const repr = JSON.stringify([error.name, error.message, source, details]);
|
|
process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
|
|
},
|
|
};
|
|
global.Chat = Chat;
|
|
process.on('uncaughtException', err => {
|
|
if (Config.crashguard) {
|
|
Monitor.crashlog(err, 'A chatlog search child process');
|
|
}
|
|
});
|
|
global.Dex = Dex;
|
|
global.toID = Dex.toID;
|
|
// eslint-disable-next-line no-eval
|
|
Repl.start('chatlog', cmd => eval(cmd));
|
|
} else {
|
|
PM.spawn(MAX_PROCESSES);
|
|
}
|
|
|
|
const accessLog = FS(`logs/chatlog-access.txt`).createAppendStream();
|
|
|
|
export const pages: PageTable = {
|
|
async chatlog(args, user, connection) {
|
|
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
|
|
if (!user.trusted) {
|
|
return this.errorReply("Access denied.");
|
|
}
|
|
let [roomid, date, opts] = Utils.splitFirst(args.join('-'), '--', 2) as
|
|
[RoomID, string | undefined, string | undefined];
|
|
if (date) date = date.trim();
|
|
if (!roomid || roomid.startsWith('-')) {
|
|
this.title = '[Logs]';
|
|
return LogViewer.list(user, roomid?.slice(1));
|
|
}
|
|
|
|
// permission check
|
|
const room = Rooms.get(roomid);
|
|
if (roomid.startsWith('spl') && roomid !== 'splatoon' && !user.can('rangeban')) {
|
|
return this.errorReply("SPL team discussions are super secret.");
|
|
}
|
|
if (roomid.startsWith('wcop') && !user.can('rangeban')) {
|
|
return this.errorReply("WCOP team discussions are super secret.");
|
|
}
|
|
if (room) {
|
|
if (!user.can('lock')) {
|
|
if (!room.persist) return this.errorReply(`Access denied.`);
|
|
this.checkCan('mute', null, room);
|
|
}
|
|
} else {
|
|
this.checkCan('lock');
|
|
}
|
|
|
|
void accessLog.writeLine(`${user.id}: <${roomid}> ${date}`);
|
|
this.title = '[Logs] ' + roomid;
|
|
/** null = no limit */
|
|
let limit: number | null = null;
|
|
let search;
|
|
if (opts?.startsWith('search-')) {
|
|
let [input, limitString] = opts.split('--limit-');
|
|
input = input.slice(7);
|
|
search = Dashycode.decode(input);
|
|
if (search.length < 3) return this.errorReply(`That's too short of a search query.`);
|
|
if (limitString) {
|
|
limit = parseInt(limitString) || null;
|
|
} else {
|
|
limit = 500;
|
|
}
|
|
opts = '';
|
|
}
|
|
const isAll = (toID(date) === 'all' || toID(date) === 'alltime');
|
|
|
|
const parsedDate = new Date(date as string);
|
|
const validDateStrings = ['all', 'alltime', 'today'];
|
|
// this is apparently the best way to tell if a date is invalid
|
|
if (date && isNaN(parsedDate.getTime()) && !validDateStrings.includes(toID(date))) {
|
|
return this.errorReply(`Invalid date.`);
|
|
}
|
|
|
|
if (date && search) {
|
|
return LogSearcher.runSearch(this, search, roomid, isAll ? null : date, limit);
|
|
} else if (date) {
|
|
if (date === 'today') {
|
|
return LogViewer.day(roomid, LogReader.today(), opts);
|
|
} else if (date.split('-').length === 3) {
|
|
return LogViewer.day(roomid, parsedDate.toISOString().slice(0, 10), opts);
|
|
} else {
|
|
return LogViewer.month(roomid, parsedDate.toISOString().slice(0, 7));
|
|
}
|
|
} else {
|
|
return LogViewer.room(roomid);
|
|
}
|
|
},
|
|
};
|
|
|
|
export const commands: ChatCommands = {
|
|
chatlog(target, room, user) {
|
|
const [tarRoom, ...opts] = target.split(',');
|
|
const targetRoom = tarRoom ? Rooms.search(tarRoom) : room;
|
|
const roomid = targetRoom ? targetRoom.roomid : target;
|
|
this.parse(`/join view-chatlog-${roomid}--today${opts ? `--${opts.join('--')}` : ''}`);
|
|
},
|
|
|
|
chatloghelp() {
|
|
const strings = [
|
|
`/chatlog [optional room], [opts] - View chatlogs from the given room. `,
|
|
`If none is specified, shows logs from the room you're in. Requires: % @ * # &`,
|
|
`Supported options:`,
|
|
`<code>txt</code> - Do not render logs.`,
|
|
`<code>txt-onlychat</code> - Show only chat lines, untransformed.`,
|
|
`<code>onlychat</code> - Show only chat lines.`,
|
|
`<code>all</code> - Show all lines, including userstats and join/leave messages.`,
|
|
];
|
|
this.runBroadcast();
|
|
return this.sendReplyBox(strings.join('<br />'));
|
|
},
|
|
|
|
sl: 'searchlogs',
|
|
logsearch: 'searchlogs',
|
|
searchlog: 'searchlogs',
|
|
searchlogs(target, room) {
|
|
target = target.trim();
|
|
const args = target.split(',').map(item => item.trim());
|
|
if (!target) return this.parse('/help searchlogs');
|
|
let date = 'all';
|
|
const searches: string[] = [];
|
|
let limit = '500';
|
|
for (const arg of args) {
|
|
if (arg.startsWith('room:')) {
|
|
const id = arg.slice(5);
|
|
room = Rooms.search(id as RoomID) as Room | null;
|
|
if (!room) {
|
|
return this.errorReply(`Room "${id}" not found.`);
|
|
}
|
|
} else if (arg.startsWith('limit:')) {
|
|
limit = arg.slice(6);
|
|
} else if (arg.startsWith('date:')) {
|
|
date = arg.slice(5);
|
|
} else {
|
|
searches.push(arg);
|
|
}
|
|
}
|
|
if (!room) {
|
|
return this.parse(`/help searchlogs`);
|
|
}
|
|
return this.parse(
|
|
`/join view-chatlog-${room.roomid}--${date}--search-${Dashycode.encode(searches.join('+'))}--limit-${limit}`
|
|
);
|
|
},
|
|
searchlogshelp() {
|
|
const buffer = `<details class="readmore"><summary><code>/searchlogs [arguments]</code>: ` +
|
|
`searches logs in the current room using the <code>[arguments]</code>.</summary>` +
|
|
`A room can be specified using the argument <code>room: [roomid]</code>. Defaults to the room it is used in.<br />` +
|
|
`A limit can be specified using the argument <code>limit: [number less than or equal to 3000]</code>. Defaults to 500.<br />` +
|
|
`A date can be specified in ISO (YYYY-MM-DD) format using the argument <code>date: [month]</code> (for example, <code>date: 2020-05</code>). Defaults to searching all logs.<br />` +
|
|
`All other arguments will be considered part of the search ` +
|
|
`(if more than one argument is specified, it searches for lines containing all terms).<br />` +
|
|
"Requires: % @ # &</div>";
|
|
return this.sendReplyBox(buffer);
|
|
},
|
|
};
|