pokemon-showdown/server/roomlogs.ts
Guangcong Luo 78439b4a02
Update to ESLint 9 (#10926)
ESLint has a whole new config format, so I figure it's a good time to
make the config system saner.

- First, we no longer have separate eslint-no-types configs. Lint
  performance shouldn't be enough of a problem to justify the
  relevant maintenance complexity.

- Second, our base config should work out-of-the-box now. `npx eslint`
  will work as expected, without any CLI flags. You should still use
  `npm run lint` which adds the `--cached` flag for performance.

- Third, whatever updates I did fixed style linting, which apparently
  has been bugged for quite some time, considering all the obvious
  mixed-tabs-and-spaces issues I found in the upgrade.

Also here are some changes to our style rules. In particular:

- Curly brackets (for objects etc) now have spaces inside them. Sorry
  for the huge change. ESLint doesn't support our old style, and most
  projects use Prettier style, so we might as well match them in this way.
  See https://github.com/eslint-stylistic/eslint-stylistic/issues/415

- String + number concatenation is no longer allowed. We now
  consistently use template strings for this.
2025-02-25 20:03:46 -08:00

393 lines
12 KiB
TypeScript

/**
* Roomlogs
* Pokemon Showdown - http://pokemonshowdown.com/
*
* This handles data storage for rooms.
*
* @license MIT
*/
import { FS, Utils, type Streams } from '../lib';
import { PGDatabase, SQL, type SQLStatement } from '../lib/database';
import type { PartialModlogEntry } from './modlog';
interface RoomlogOptions {
isMultichannel?: boolean;
noAutoTruncate?: boolean;
noLogTimes?: boolean;
}
interface RoomlogRow {
type: string;
roomid: string;
userid: string | null;
time: Date;
log: string;
// tsvector, really don't use
content: string | null;
}
export const roomlogDB = (() => {
if (!global.Config || !Config.replaysdb || Config.disableroomlogdb) return null;
return new PGDatabase(Config.replaysdb);
})();
export const roomlogTable = roomlogDB?.getTable<RoomlogRow>('roomlogs');
/**
* Most rooms have three logs:
* - scrollback
* - roomlog
* - modlog
* This class keeps track of all three.
*
* The scrollback is stored in memory, and is the log you get when you
* join the room. It does not get moderator messages.
*
* The modlog is stored in
* `logs/modlog/modlog_<ROOMID>.txt`
* It contains moderator messages, formatted for ease of search.
* Direct modlog access is handled in server/modlog/; this file is just
* a wrapper to make other code more readable.
*
* The roomlog is stored in
* `logs/chat/<ROOMID>/<YEAR>-<MONTH>/<YEAR>-<MONTH>-<DAY>.txt`
* It contains (nearly) everything.
*/
export class Roomlog {
/**
* Battle rooms are multichannel, which means their logs are split
* into four channels, public, p1, p2, full.
*/
readonly isMultichannel: boolean;
/**
* Chat rooms auto-truncate, which means it only stores the recent
* messages, if there are more.
*/
readonly noAutoTruncate: boolean;
/**
* Chat rooms include timestamps.
*/
readonly noLogTimes: boolean;
roomid: RoomID;
/**
* Scrollback log
*/
log: string[];
visibleMessageCount = 0;
broadcastBuffer: string[];
/**
* undefined = uninitialized,
* null = disabled
*/
roomlogStream?: Streams.WriteStream | null;
/**
* Takes precedence over roomlogStream if it exists.
*/
roomlogTable: typeof roomlogTable;
roomlogFilename: string;
numTruncatedLines: number;
constructor(room: BasicRoom, options: RoomlogOptions = {}) {
this.roomid = room.roomid;
this.isMultichannel = !!options.isMultichannel;
this.noAutoTruncate = !!options.noAutoTruncate;
this.noLogTimes = !!options.noLogTimes;
this.log = [];
this.broadcastBuffer = [];
this.roomlogStream = undefined;
this.roomlogFilename = '';
this.numTruncatedLines = 0;
this.setupRoomlogStream();
}
getScrollback(channel = 0) {
let log = this.log;
if (!this.noLogTimes) log = [`|:|${~~(Date.now() / 1000)}`].concat(log);
if (!this.isMultichannel) {
return log.join('\n') + '\n';
}
log = [];
for (let i = 0; i < this.log.length; ++i) {
const line = this.log[i];
const split = /\|split\|p(\d)/g.exec(line);
if (split) {
const canSeePrivileged = (channel === Number(split[1]) || channel === -1);
const ownLine = this.log[i + (canSeePrivileged ? 1 : 2)];
if (ownLine) log.push(ownLine);
i += 2;
} else {
log.push(line);
}
}
return log.join('\n') + '\n';
}
setupRoomlogStream() {
if (this.roomlogStream === null) return;
if (!Config.logchat || this.roomid.startsWith('battle-') || this.roomid.startsWith('game-')) {
this.roomlogStream = null;
return;
}
if (roomlogTable) {
this.roomlogTable = roomlogTable;
this.roomlogStream = null;
return;
}
const date = new Date();
const dateString = Chat.toTimestamp(date).split(' ')[0];
const monthString = dateString.split('-', 2).join('-');
const basepath = `chat/${this.roomid}/`;
const relpath = `${monthString}/${dateString}.txt`;
if (relpath === this.roomlogFilename) return;
Monitor.logPath(basepath + monthString).mkdirpSync();
this.roomlogFilename = relpath;
if (this.roomlogStream) void this.roomlogStream.writeEnd();
this.roomlogStream = Monitor.logPath(basepath + relpath).createAppendStream();
// Create a symlink to today's lobby log.
// These operations need to be synchronous, but it's okay
// because this code is only executed once every 24 hours.
const link0 = basepath + 'today.txt.0';
Monitor.logPath(link0).unlinkIfExistsSync();
try {
Monitor.logPath(link0).symlinkToSync(relpath); // intentionally a relative link
Monitor.logPath(link0).renameSync(basepath + 'today.txt');
} catch {} // OS might not support symlinks or atomic rename
if (!Roomlogs.rollLogTimer) Roomlogs.rollLogs();
}
add(message: string) {
this.roomlog(message);
// |uhtml gets both uhtml and uhtmlchange
// which are visible and so should be counted
if (['|c|', '|c:|', '|raw|', '|html|', '|uhtml'].some(k => message.startsWith(k))) {
this.visibleMessageCount++;
}
message = this.withTimestamp(message);
this.log.push(message);
this.broadcastBuffer.push(message);
return this;
}
private withTimestamp(message: string) {
if (!this.noLogTimes && message.startsWith('|c|')) {
return `|c:|${Math.trunc(Date.now() / 1000)}|${message.slice(3)}`;
} else {
return message;
}
}
hasUsername(username: string) {
const userid = toID(username);
for (const line of this.log) {
if (line.startsWith('|c:|')) {
const curUserid = toID(line.split('|', 4)[3]);
if (curUserid === userid) return true;
} else if (line.startsWith('|c|')) {
const curUserid = toID(line.split('|', 3)[2]);
if (curUserid === userid) return true;
}
}
return false;
}
clearText(userids: ID[], lineCount = 0) {
const cleared: ID[] = [];
const clearAll = (lineCount === 0);
this.log = this.log.reverse().filter(line => {
const parsed = this.parseChatLine(line);
if (parsed) {
const userid = toID(parsed.user);
if (userids.includes(userid)) {
if (!cleared.includes(userid)) cleared.push(userid);
// Don't remove messages in battle rooms to preserve evidence
if (!this.roomlogStream && !this.roomlogTable) return true;
if (clearAll) return false;
if (lineCount > 0) {
lineCount--;
return false;
}
return true;
}
}
return true;
}).reverse();
return cleared;
}
uhtmlchange(name: string, message: string) {
const originalStart = '|uhtml|' + name + '|';
const fullMessage = originalStart + message;
for (const [i, line] of this.log.entries()) {
if (line.startsWith(originalStart)) {
this.log[i] = fullMessage;
break;
}
}
this.broadcastBuffer.push(fullMessage);
}
attributedUhtmlchange(user: User, name: string, message: string) {
const start = `/uhtmlchange ${name},`;
const fullMessage = this.withTimestamp(`|c|${user.getIdentity()}|${start}${message}`);
let matched = false;
for (const [i, line] of this.log.entries()) {
if (this.parseChatLine(line)?.message.startsWith(start)) {
this.log[i] = fullMessage;
matched = true;
break;
}
}
if (!matched) this.log.push(fullMessage);
this.broadcastBuffer.push(fullMessage);
}
parseChatLine(line: string) {
const prefixes: [string, number][] = [['|c:|', 4], ['|c|', 3]];
for (const [messageStart, section] of prefixes) {
// const messageStart = !this.noLogTimes ? '|c:|' : '|c|';
// const section = !this.noLogTimes ? 4 : 3; // ['', 'c' timestamp?, author, message]
if (line.startsWith(messageStart)) {
const parts = Utils.splitFirst(line, '|', section);
return { user: parts[section - 1], message: parts[section] };
}
}
}
roomlog(message: string, date = new Date()) {
if (!Config.logchat) return;
message = message.replace(/<img[^>]* src="data:image\/png;base64,[^">]+"[^>]*>/g, '[img]');
if (this.roomlogTable) {
const chatData = this.parseChatLine(message);
const type = message.split('|')[1] || "";
void this.insertLog(SQL`INSERT INTO roomlogs (${{
type,
roomid: this.roomid,
userid: toID(chatData?.user) || null,
time: SQL`now()`,
log: message,
}})`);
const dateStr = Chat.toTimestamp(date).split(' ')[0];
void this.insertLog(SQL`INSERT INTO roomlog_dates (${{
roomid: this.roomid,
month: dateStr.slice(0, -3),
date: dateStr,
}}) ON CONFLICT (roomid, date) DO NOTHING;`);
} else if (this.roomlogStream) {
const timestamp = Chat.toTimestamp(date).split(' ')[1] + ' ';
void this.roomlogStream.write(timestamp + message + '\n');
}
}
private async insertLog(query: SQLStatement, ignoreFailure = false, retries = 3): Promise<void> {
try {
await this.roomlogTable?.query(query);
} catch (e: any) {
if (e?.code === '42P01') { // table not found
await roomlogDB!._query(FS('databases/schemas/roomlogs.sql').readSync(), []);
return this.insertLog(query, ignoreFailure, retries);
}
// connection terminated / transient errors
if (
!ignoreFailure &&
retries > 0 &&
e.message?.includes('Connection terminated unexpectedly')
) {
// delay before retrying
await new Promise(resolve => { setTimeout(resolve, 2000); });
return this.insertLog(query, ignoreFailure, retries - 1);
}
// crashlog for all other errors
const [q, vals] = roomlogDB!._resolveSQL(query);
Monitor.crashlog(e, 'a roomlog database query', {
query: q, values: vals,
});
}
}
modlog(entry: PartialModlogEntry, overrideID?: string) {
void Rooms.Modlog.write(this.roomid, entry, overrideID);
}
async rename(newID: RoomID): Promise<true> {
await Rooms.Modlog.rename(this.roomid, newID);
const roomlogStreamExisted = this.roomlogStream !== null;
await this.destroy();
if (this.roomlogTable) {
await this.roomlogTable.updateAll({ roomid: newID })`WHERE roomid = ${this.roomid}`;
} else {
const roomlogPath = `chat`;
const [roomlogExists, newRoomlogExists] = await Promise.all([
Monitor.logPath(roomlogPath + `/${this.roomid}`).exists(),
Monitor.logPath(roomlogPath + `/${newID}`).exists(),
]);
if (roomlogExists && !newRoomlogExists) {
await Monitor.logPath(roomlogPath + `/${this.roomid}`).rename(Monitor.logPath(roomlogPath + `/${newID}`).path);
}
if (roomlogStreamExisted) {
this.roomlogStream = undefined;
this.roomlogFilename = "";
this.setupRoomlogStream();
}
}
Roomlogs.roomlogs.set(newID, this);
this.roomid = newID;
return true;
}
static rollLogs(this: void) {
if (Roomlogs.rollLogTimer === true) return;
if (Roomlogs.rollLogTimer) {
clearTimeout(Roomlogs.rollLogTimer);
}
Roomlogs.rollLogTimer = true;
for (const log of Roomlogs.roomlogs.values()) {
log.setupRoomlogStream();
}
const time = Date.now();
const nextMidnight = new Date();
nextMidnight.setHours(24, 0, 0, 0);
Roomlogs.rollLogTimer = setTimeout(() => Roomlog.rollLogs(), nextMidnight.getTime() - time);
}
truncate() {
if (this.noAutoTruncate) return;
if (this.log.length > 100) {
const truncationLength = this.log.length - 100;
this.log.splice(0, truncationLength);
this.numTruncatedLines += truncationLength;
}
}
/**
* Returns the total number of lines in the roomlog, including truncated lines.
*/
getLineCount(onlyVisible = true) {
return (onlyVisible ? this.visibleMessageCount : this.log.length) + this.numTruncatedLines;
}
destroy() {
const promises = [];
if (this.roomlogStream) {
promises.push(this.roomlogStream.writeEnd());
this.roomlogStream = null;
}
Roomlogs.roomlogs.delete(this.roomid);
return Promise.all(promises);
}
}
const roomlogs = new Map<string, Roomlog>();
function createRoomlog(room: BasicRoom, options = {}) {
let roomlog = Roomlogs.roomlogs.get(room.roomid);
if (roomlog) throw new Error(`Roomlog ${room.roomid} already exists`);
roomlog = new Roomlog(room, options);
Roomlogs.roomlogs.set(room.roomid, roomlog);
return roomlog;
}
export const Roomlogs = {
create: createRoomlog,
Roomlog,
roomlogs,
db: roomlogDB,
table: roomlogTable,
rollLogs: Roomlog.rollLogs,
rollLogTimer: null as NodeJS.Timeout | true | null,
};