`;
}
buf += ``;
return this.linkify(buf);
}
async battle(tier: string, number: number, context: Chat.PageContext) {
if (number > Rooms.global.lastBattle) {
throw new Chat.ErrorMessage(`That battle cannot exist, as the number has not been used.`);
}
const roomid = `battle-${tier}-${number}` as RoomID;
context.setHTML(`
Locating battle logs for the battle ${tier}-${number}...
Searching linecounts on room ${roomid}${user ? ` for the user ${user}` : ''}.
`
);
const results = await PM.query({roomid, date: month, search: user, queryType: 'linecount'});
context.setHTML(results);
}
async sharedBattles(userids: string[]) {
let buf = `Logged shared battles between the users ${userids.join(', ')}`;
const results: string[] = await PM.query({
queryType: 'sharedsearch', search: userids,
});
if (!results.length) {
buf += `: None found.`;
return buf;
}
buf += ` (${results.length}): `;
buf += results.map(id => `${id}`).join(', ');
return buf;
}
// this would normally be abstract, but it's very difficult with ripgrep
// so it's easier to just do it the same way for both.
async roomStats(room: RoomID, month: string) {
if (!FS(`logs/chat/${room}`).existsSync()) {
return LogViewer.error(Utils.html`Room ${room} not found.`);
}
if (!FS(`logs/chat/${room}/${month}`).existsSync()) {
return LogViewer.error(Utils.html`Room ${room} does not have logs for the month ${month}.`);
}
const stats = await PM.query({
queryType: 'roomstats', search: month, roomid: room,
});
let buf = `
Room stats for ${room} [${month}]
`;
buf += `Total days with logs: ${stats.average.days} `;
const next = LogReader.nextMonth(month);
const prev = LogReader.prevMonth(month);
const prevExists = FS(`logs/chat/${room}/${prev}`).existsSync();
const nextExists = FS(`logs/chat/${room}/${next}`).existsSync();
if (prevExists) {
buf += ` Previous month`;
buf += nextExists ? ` | ` : ` `;
}
if (nextExists) {
buf += `${prevExists ? `` : ` `}Next month `;
}
buf += this.visualizeStats(stats.average);
buf += ``;
buf += `Stats by day`;
for (const day of stats.days) {
buf += `
`)}`;
buf += ``;
}
return buf;
}
async fsSearchMonth(opts: ChatlogSearch) {
let {limit, room: roomid, date: month, search} = opts;
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({room: roomid, date: 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 runYearSearch(roomid: RoomID, year: string | null, search: string, limit: number) {
const {results, total} = await this.fsSearchYear(roomid, year, search, limit);
if (!total) {
return LogViewer.error(`No matches found for ${search} on ${roomid}.`);
}
let buf = '';
if (year) {
buf += `
Searching year: ${year}: `;
} else {
buf += `
Searching all logs: `;
}
buf += this.renderDayResults(results, roomid);
if (total > limit) {
// cap is met
buf += ` Max results reached, capped at ${limit}`;
buf += `
`;
if (total < MAX_RESULTS) {
buf += ``;
buf += `
`;
}
}
this.results = 0;
return buf;
}
async runMonthSearch(roomid: RoomID, month: string, search: string, limit: number, year = false) {
const {results, total} = await this.fsSearchMonth({room: roomid, date: month, search, limit});
if (!total) {
return LogViewer.error(`No matches found for ${search} on ${roomid}.`);
}
let buf = (
`
Searching for "${search}" in ${roomid} (${month}):`
);
buf += this.renderDayResults(results, roomid);
if (total > limit) {
// cap is met & is not being used in a year read
buf += ` Max results reached, capped at ${limit}`;
buf += `
`;
if (total < MAX_RESULTS) {
buf += ``;
buf += `
`;
}
}
buf += `
`;
this.results = 0;
return buf;
}
async getSharedBattles(userids: string[]) {
const months = FS("logs/").readdirSync().filter(f => !isNaN(new Date(f).getTime()));
const results: string[] = [];
for (const month of months) {
const tiers = await FS(`logs/${month}`).readdir();
for (const tier of tiers) {
const days = await FS(`logs/${month}/${tier}/`).readdir();
for (const day of days) {
const battles = await FS(`logs/${month}/${tier}/${day}`).readdir();
for (const battle of battles) {
const content = JSON.parse(FS(`logs/${month}/${tier}/${day}/${battle}`).readSync());
const players = [content.p1, content.p2].map(toID);
if (players.every(p => userids.includes(p))) {
const battleName = battle.slice(0, -9);
results.push(battleName);
}
}
}
}
}
return results;
}
}
export class RipgrepLogSearcher extends Searcher {
async ripgrepSearchMonth(opts: ChatlogSearch) {
let {raw, search, room: roomid, date: month, args} = opts;
let results: string[];
let lineCount = 0;
if (!raw) {
search = this.constructSearchRegex(search);
}
const resultSep = args?.includes('-m') ? '--' : '\n';
try {
const options = [
'-e', search,
`logs/chat/${roomid}/${month}`,
'-i',
];
if (args) {
options.push(...args);
}
const {stdout} = await ProcessManager.exec(['rg', ...options], {
maxBuffer: MAX_MEMORY,
cwd: `${__dirname}/../../`,
});
results = stdout.split(resultSep);
} catch (e: any) {
if (e.code !== 1 && !e.message.includes('stdout maxBuffer') && !e.message.includes('No such file or directory')) {
throw e; // 2 means an error in ripgrep
}
if (e.stdout) {
results = e.stdout.split(resultSep);
} else {
results = [];
}
}
lineCount += results.length;
return {results, lineCount};
}
async searchLogs(
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 linecount = 0;
let results: string[] = [];
if (!limit || limit > MAX_RESULTS) limit = MAX_RESULTS;
if (!date) date = 'all';
const originalSearch = search;
const userRegex = /user-(.[a-zA-Z0-9]*)/gi;
const user = userRegex.exec(search)?.[0]?.slice(5);
const userSearch = user ? `the user '${user}'` : null;
if (userSearch) {
const id = toID(user);
const rest = search.replace(userRegex, '')
.split('-')
.filter(Boolean)
.map(str => `.*${Utils.escapeRegex(str)}`)
.join('');
search = `\\|c\\|${this.constructUserRegex(id)}\\|${rest}`;
}
while (linecount < MAX_RESULTS) {
const month = months.shift();
if (!month) break;
const output = await this.ripgrepSearchMonth({
room: roomid, search, date: month,
limit, args: [`-m`, `${limit}`, '-C', '3', '--engine=auto'], raw: !!userSearch,
});
results = results.concat(output.results);
linecount += output.lineCount;
}
if (linecount > MAX_RESULTS) {
const diff = linecount - MAX_RESULTS;
results = results.slice(0, -diff);
}
return this.renderSearchResults(results, roomid, search, limit, date, originalSearch);
}
renderSearchResults(
results: string[], roomid: RoomID, search: string, limit: number,
month?: string | null, originalSearch?: 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 useOriginal = originalSearch && originalSearch !== search;
const searchRegex = new RegExp(useOriginal ? search : this.constructSearchRegex(search), "i");
const sorted = Utils.sortBy(results, line => (
{reverse: line.split('.txt')[0].split('/').pop()!}
)).map(chunk => chunk.split('\n').map(rawLine => {
if (exactMatches > limit || !toID(rawLine)) return null; // return early so we don't keep sorting
const sep = rawLine.includes('.txt-') ? '.txt-' : '.txt:';
const [name, text] = rawLine.split(sep);
let 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(rawLine)) {
if (++exactMatches > limit) return null;
line = `
${line}
`;
}
if (curDate !== date) {
curDate = date;
date = `
[${date}]`;
} else {
date = '';
}
return `${date} ${line}`;
}).filter(Boolean).join(' ')).filter(Boolean);
let buf = Utils.html`
Results on ${roomid} for ${originalSearch ? originalSearch : search}:`;
buf += limit ? ` ${exactMatches} (capped at ${limit})` : '';
buf += `
`;
buf += sorted.join('');
if (limit) {
buf += `
Capped at ${limit}. `;
buf += ``;
buf += `
`;
}
return buf;
}
async searchLinecounts(room: RoomID, month: string, user?: ID) {
// don't need to check if logs exist since ripgrepSearchMonth does that
const regexString = (
user ? `\\|c\\|${this.constructUserRegex(user)}\\|` : `\\|c\\|([^|]+)\\|`
) + `(?!\\/uhtml(change)?)`;
const args: string[] = user ? ['--count'] : [];
args.push(`--pcre2`);
const {results: rawResults} = await this.ripgrepSearchMonth({
search: regexString, raw: true, date: month, room, args,
});
const results: {[k: string]: {[userid: string]: number}} = {};
for (const fullLine of rawResults) {
const [data, line] = fullLine.split('.txt:');
const date = data.split('/').pop()!;
if (!results[date]) results[date] = {};
if (!toID(date)) continue;
if (user) {
if (!results[date][user]) results[date][user] = 0;
const parsed = parseInt(line);
results[date][user] += isNaN(parsed) ? 0 : parsed;
} else {
const parts = line?.split('|').map(toID);
if (!parts || parts[1] !== 'c') continue;
const id = parts[2];
if (!id) continue;
if (!results[date][id]) results[date][id] = 0;
results[date][id]++;
}
}
return this.renderLinecountResults(results, room, month, user);
}
async getSharedBattles(userids: string[]) {
const regexString = userids.map(id => `(?=.*?("p(1|2)":"${[...id].join('[^a-zA-Z0-9]*')}[^a-zA-Z0-9]*"))`).join('');
const results: string[] = [];
try {
const {stdout} = await ProcessManager.exec(['rg', '-e', regexString, '-i', '-tjson', 'logs/', '-P']);
for (const line of stdout.split('\n')) {
const [name] = line.split(':');
const battleName = name.split('/').pop()!;
results.push(battleName.slice(0, -9));
}
} catch (e: any) {
if (e.code !== 1) throw e;
}
return results.filter(Boolean);
}
}
export const LogSearcher: Searcher = new (Config.chatlogreader === 'ripgrep' ? RipgrepLogSearcher : FSLogSearcher)();
export const PM = new ProcessManager.QueryProcessManager(module, async data => {
const start = Date.now();
try {
let result: any;
const {date, search, roomid, limit, queryType} = data;
switch (queryType) {
case 'linecount':
result = await LogSearcher.searchLinecounts(roomid, date, search);
break;
case 'search':
result = await LogSearcher.searchLogs(roomid, search, limit, date);
break;
case 'sharedsearch':
result = await LogSearcher.getSharedBattles(search);
break;
case 'battlesearch':
result = await LogReader.findBattleLog(roomid, search);
break;
case 'roomstats':
result = await LogSearcher.activityStats(roomid, search);
break;
default:
return LogViewer.error(`Config.chatlogreader is not configured.`);
}
const elapsedTime = Date.now() - start;
if (elapsedTime > 3000) {
Monitor.slow(`[Slow chatlog query]: ${elapsedTime}ms: ${JSON.stringify(data)}`);
}
return result;
} catch (e: any) {
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.`);
}
}, CHATLOG_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 = '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}`);
},
slow(text: string) {
process.send!(`CALLBACK\nSLOW\n${text}`);
},
};
global.Dex = Dex;
global.toID = Dex.toID;
process.on('uncaughtException', err => {
if (Config.crashguard) {
Monitor.crashlog(err, 'A chatlog search child process');
}
});
// 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: Chat.PageTable = {
async chatlog(args, user, connection) {
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
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 (!user.trusted) {
if (room) {
this.checkCan('declare', null, room);
} else {
return this.errorReply(`Access denied.`);
}
}
if (!user.can('rangeban')) {
// Some chatlogs can only be viewed by upper staff
if (roomid.startsWith('spl') && roomid !== 'splatoon') {
return this.errorReply("SPL team discussions are super secret.");
}
if (roomid.startsWith('wcop')) {
return this.errorReply("WCOP team discussions are super secret.");
}
if (UPPER_STAFF_ROOMS.includes(roomid) && !user.inRooms.has(roomid)) {
return this.errorReply("Upper staff rooms are super secret.");
}
}
if (room) {
if (!user.can('lock') || room.settings.isPrivate === 'hidden' && !room.checkModjoin(user)) {
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'];
const validNonDateTerm = search ? validDateStrings.includes(date!) : date === 'today';
// this is apparently the best way to tell if a date is invalid
if (date && isNaN(parsedDate.getTime()) && !validNonDateTerm) {
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);
}
},
roomstats(args, user) {
const room = this.extractRoom();
if (room) {
this.checkCan('mute', null, room);
} else {
if (!user.can('bypassall')) {
return this.errorReply(`You cannot view logs for rooms that no longer exist.`);
}
}
const [, date, target] = Utils.splitFirst(args.join('-'), '--', 3).map(item => item.trim());
if (isNaN(new Date(date).getTime())) {
return this.errorReply(`Invalid date.`);
}
if (!LogReader.isMonth(date)) {
return this.errorReply(`You must specify an exact month - both a year and a month.`);
}
this.title = `[Log Stats] ${date}`;
return LogSearcher.runLinecountSearch(this, room ? room.roomid : args[2] as RoomID, date, toID(target));
},
battlelog(args, user) {
const [tierName, battleNum] = args;
const tier = toID(tierName);
const num = parseInt(battleNum);
if (isNaN(num)) return this.errorReply(`Invalid battle number.`);
void accessLog.writeLine(`${user.id}: battle-${tier}-${num}`);
return LogViewer.battle(tier, num, this);
},
async logsaccess(query) {
this.checkCan('rangeban');
const type = toID(query.shift());
if (type && !['chat', 'battle', 'all', 'battles'].includes(type)) {
return this.errorReply(`Invalid log type.`);
}
let title = '';
switch (type) {
case 'battle': case 'battles':
title = 'Battlelog access log';
break;
case 'chat':
title = 'Chatlog access log';
break;
default:
title = 'Logs access log';
break;
}
const userid = toID(query.shift());
let buf = `
${title}`;
if (userid) buf += ` for ${userid}`;
buf += `
`;
const accessStream = FS(`logs/chatlog-access.txt`).createReadStream();
for await (const line of accessStream.byLine()) {
const [id, rest] = Utils.splitFirst(line, ': ');
if (userid && id !== userid) continue;
if (type === 'battle' && !line.includes('battle-')) continue;
if (userid) {
buf += `
`);
return LogSearcher.roomStats(roomid, date);
},
};
export const commands: Chat.ChatCommands = {
chatlogs: 'chatlog',
cl: 'chatlog',
chatlog(target, room, user) {
const [tarRoom, ...opts] = target.split(',');
const targetRoom = tarRoom ? Rooms.search(tarRoom) : room;
const roomid = targetRoom ? targetRoom.roomid : target;
return 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:`,
`txt - Do not render logs.`,
`txt-onlychat - Show only chat lines, untransformed.`,
`onlychat - Show only chat lines.`,
`all - Show all lines, including userstats and join/leave messages.`,
];
this.runBroadcast();
return this.sendReplyBox(strings.join(' '));
},
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';
let targetRoom: RoomID | undefined = room?.roomid;
for (const arg of args) {
if (arg.startsWith('room=')) {
const id = arg.slice(5).trim().toLowerCase() as RoomID;
if (!FS(`logs/chat/${id}`).existsSync()) {
return this.errorReply(`Room "${id}" not found.`);
}
targetRoom = id;
} else if (arg.startsWith('limit=')) {
limit = arg.slice(6);
} else if (arg.startsWith('date=')) {
date = arg.slice(5);
} else if (arg.startsWith('user=')) {
args.push(`user-${toID(arg.slice(5))}`);
} else {
searches.push(arg);
}
}
if (!targetRoom) {
return this.parse(`/help searchlogs`);
}
return this.parse(
`/join view-chatlog-${targetRoom}--${date}--search-` +
`${Dashycode.encode(searches.join('+'))}--limit-${limit}`
);
},
searchlogshelp() {
const buffer = `/searchlogs [arguments]: ` +
`searches logs in the current room using the [arguments].` +
`A room can be specified using the argument room=[roomid]. Defaults to the room it is used in. ` +
`A limit can be specified using the argument limit=[number less than or equal to 3000]. Defaults to 500. ` +
`A date can be specified in ISO (YYYY-MM-DD) format using the argument date=[month] (for example, date: 2020-05). Defaults to searching all logs. ` +
`If you provide a user argument in the form user=username, it will search for messages (that match the other arguments) only from that user. ` +
`All other arguments will be considered part of the search ` +
`(if more than one argument is specified, it searches for lines containing all terms). ` +
"Requires: % @ # &
";
return this.sendReplyBox(buffer);
},
topusers: 'linecount',
roomstats: 'linecount',
linecount(target, room, user) {
const params = target.split(',').map(f => f.trim());
const search: Partial<{roomid: RoomID, date: string, user: string}> = {};
for (const [i, param] of params.entries()) {
let [key, val] = param.split('=');
if (!val) {
// backwards compatibility
switch (i) {
case 0:
val = key;
key = 'room';
break;
case 1:
val = key;
key = 'date';
break;
case 2:
val = key;
key = 'user';
break;
default:
return this.parse(`/help linecount`);
}
}
if (!toID(val)) continue; // unset, continue and allow defaults to apply
key = key.toLowerCase().replace(/ /g, '');
switch (key) {
case 'room': case 'roomid':
const tarRoom = Rooms.search(val);
if (!tarRoom) {
return this.errorReply(`Room '${val}' not found.`);
}
search.roomid = tarRoom.roomid;
break;
case 'user': case 'id': case 'userid':
search.user = toID(val);
break;
case 'date': case 'month': case 'time':
if (!LogReader.isMonth(val)) {
return this.errorReply(`Invalid date.`);
}
search.date = val;
}
}
if (!search.roomid) {
if (!room) {
return this.errorReply(`If you're not specifying a room, you must use this command in a room.`);
}
search.roomid = room.roomid;
}
if (!search.date) {
search.date = LogReader.getMonth();
}
return this.parse(`/join view-roomstats-${search.roomid}--${search.date}${search.user ? `--${search.user}` : ''}`);
},
linecounthelp() {
return this.sendReplyBox(
`/linecount OR /roomstats OR /topusers [key=value formatted parameters] - ` +
`Searches linecounts with the given parameters. ` +
`Parameters:` +
`- room (aliases: roomid) - Select a room to search. If no room is given, defaults to current room.` +
`- date (aliases: month, time) - ` +
`Select a month to search linecounts on (requires YYYY-MM format). Defaults to current month. ` +
`- user (aliases: id, userid) - ` +
`Searches for linecounts only from a given user. ` +
`If this is not provided, /linecount instead shows line counts for all users from that month.` +
`Parameters may also be specified without a [key]. When using this, arguments are provided in the format ` +
`/linecount [room], [month], [user].. This does not use any defaults. `
);
},
slb: 'sharedloggedbattles',
async sharedloggedbattles(target, room, user) {
this.checkCan('lock');
if (Config.nobattlesearch) return this.errorReply(`/${this.cmd} has been temporarily disabled due to load issues.`);
const targets = target.split(',').map(toID).filter(Boolean);
if (targets.length < 2 || targets.length > 2) {
return this.errorReply(`Specify two users.`);
}
const results = await LogSearcher.sharedBattles(targets);
if (room?.settings.staffRoom || this.pmTarget?.isStaff) {
this.runBroadcast();
}
return this.sendReplyBox(results);
},
sharedloggedbattleshelp: [
`/sharedloggedbattles OR /slb [user1, user2] - View shared battle logs between user1 and user2`,
],
battlelog(target, room, user) {
this.checkCan('lock');
target = target.trim();
if (!target) return this.errorReply(`Specify a battle.`);
if (target.startsWith('http://')) target = target.slice(7);
if (target.startsWith('https://')) target = target.slice(8);
if (target.startsWith(`${Config.routes.client}/`)) target = target.slice(Config.routes.client.length + 1);
if (target.startsWith(`${Config.routes.replays}/`)) target = `battle-${target.slice(Config.routes.replays.length + 1)}`;
if (target.startsWith('psim.us/')) target = target.slice(8);
return this.parse(`/join view-battlelog-${target}`);
},
battleloghelp: [
`/battlelog [battle link] - View the log of the given [battle link], even if the replay was not saved.`,
`Requires: % @ &`,
],
gbc: 'getbattlechat',
async getbattlechat(target, room, user) {
this.checkCan('lock');
let [roomName, userName] = Utils.splitFirst(target, ',').map(f => f.trim());
if (!roomName) {
if (!room) {
return this.errorReply(`If you are not specifying a room, use this command in a room.`);
}
roomName = room.roomid;
}
if (roomName.startsWith('http://')) roomName = roomName.slice(7);
if (roomName.startsWith('https://')) roomName = roomName.slice(8);
if (roomName.startsWith(`${Config.routes.client}/`)) {
roomName = roomName.slice(Config.routes.client.length + 1);
}
if (roomName.startsWith(`${Config.routes.replays}/`)) {
roomName = `battle-${roomName.slice(Config.routes.replays.length + 1)}`;
}
if (roomName.startsWith('psim.us/')) roomName = roomName.slice(8);
const roomid = roomName.toLowerCase().replace(/[^a-z0-9-]+/g, '') as RoomID;
if (!roomid) return this.parse('/help getbattlechat');
const userid = toID(userName);
if (userName && !userid) return this.errorReply(`Invalid username.`);
if (!roomid.startsWith('battle-')) return this.errorReply(`You must specify a battle.`);
const tarRoom = Rooms.get(roomid);
let log: string[];
if (tarRoom) {
log = tarRoom.log.log;
} else {
try {
const raw = await Net(`https://${Config.routes.replays}/${roomid.slice('battle-'.length)}.json`).get();
const data = JSON.parse(raw);
log = data.log ? data.log.split('\n') : [];
} catch {
return this.errorReply(`No room or replay found for that battle.`);
}
}
log = log.filter(l => l.startsWith('|c|'));
let buf = '';
let atLeastOne = false;
let i = 0;
for (const line of log) {
const [,, username, message] = Utils.splitFirst(line, '|', 3);
if (userid && toID(username) !== userid) continue;
i++;
buf += Utils.html`
${username}: ${message}
`;
atLeastOne = true;
}
if (i > 20) buf = `${buf}`;
if (!atLeastOne) buf = ` None found.`;
this.runBroadcast();
return this.sendReplyBox(
Utils.html`Chat messages in the battle '${roomid}'` +
(userid ? `from the user '${userid}'` : "") + `` +
buf
);
},
getbattlechathelp: [
`/getbattlechat [battle link][, username] - Gets all battle chat logs from the given [battle link].`,
`If a [username] is given, searches only chat messages from the given username.`,
`Requires: % @ &`,
],
logsaccess(target, room, user) {
this.checkCan('rangeban');
const [type, userid] = target.split(',').map(toID);
return this.parse(`/j view-logsaccess-${type || 'all'}${userid ? `-${userid}` : ''}`);
},
logsaccesshelp: [
`/logsaccess [type], [user] - View chatlog access logs for the given [type] and [user].`,
`If no arguments are given, shows the entire access log.`,
`Requires: &`,
],
gcsearch: 'groupchatsearch',
async groupchatsearch(target, room, user) {
this.checkCan('lock');
target = target.toLowerCase().replace(/[^a-z0-9-]+/g, '');
if (!target) return this.parse(`/help groupchatsearch`);
if (target.length < 3) {
return this.errorReply(`Too short of a search term.`);
}
const files = await FS(`logs/chat`).readdir();
const buffer = [];
for (const roomid of files) {
if (roomid.startsWith('groupchat-') && roomid.includes(target)) {
buffer.push(roomid);
}
}
Utils.sortBy(buffer, roomid => !!Rooms.get(roomid));
return this.sendReplyBox(
`Groupchats with a roomid matching '${target}': ` +
(buffer.length ? buffer.map(id => `${id}`).join('; ') : 'None found.')
);
},
groupchatsearchhelp: [
`/groupchatsearch [target] - Searches for logs of groupchats with names containing the [target]. Requires: % @ &`,
],
roomact: 'roomactivity',
roomactivity(target, room, user) {
this.checkCan('bypassall');
const [id, date] = target.split(',').map(i => i.trim());
if (id) room = Rooms.search(toID(id)) as Room | null;
if (!room) return this.errorReply(`Either use this command in the target room or specify a room.`);
return this.parse(`/join view-roominfo-${room}${date ? `--${date}` : ''}`);
},
roomactivityhelp: [
`/roomactibity [room][, date] - View room activity logs for the given room.`,
`If a date is provided, it searches for logs from that date. Otherwise, it searches the current month.`,
`Requires: &`,
],
};