Pokemon_server / server /roomlogs.ts
Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* 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,
};