mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-04-18 16:47:22 -05:00
316 lines
9.2 KiB
TypeScript
316 lines
9.2 KiB
TypeScript
/**
|
|
* Roomlogs
|
|
* Pokemon Showdown - http://pokemonshowdown.com/
|
|
*
|
|
* This handles data storage for rooms.
|
|
*
|
|
* @license MIT
|
|
*/
|
|
|
|
import {FS} from '../lib/fs';
|
|
import {Utils} from '../lib/utils';
|
|
import type {PartialModlogEntry} from './modlog';
|
|
|
|
interface RoomlogOptions {
|
|
isMultichannel?: boolean;
|
|
noAutoTruncate?: boolean;
|
|
noLogTimes?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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 modlog.ts; 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[];
|
|
broadcastBuffer: string[];
|
|
/**
|
|
* undefined = uninitialized,
|
|
* null = disabled
|
|
*/
|
|
roomlogStream?: Streams.WriteStream | null;
|
|
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;
|
|
|
|
Rooms.Modlog.initialize(this.roomid);
|
|
void this.setupRoomlogStream(true);
|
|
}
|
|
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[0]) || 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';
|
|
}
|
|
async setupRoomlogStream(sync = false) {
|
|
if (this.roomlogStream === null) return;
|
|
if (!Config.logchat) {
|
|
this.roomlogStream = null;
|
|
return;
|
|
}
|
|
if (this.roomid.startsWith('battle-')) {
|
|
this.roomlogStream = null;
|
|
return;
|
|
}
|
|
const date = new Date();
|
|
const dateString = Chat.toTimestamp(date).split(' ')[0];
|
|
const monthString = dateString.split('-', 2).join('-');
|
|
const basepath = `logs/chat/${this.roomid}/`;
|
|
const relpath = `${monthString}/${dateString}.txt`;
|
|
|
|
if (relpath === this.roomlogFilename) return;
|
|
|
|
if (sync) {
|
|
FS(basepath + monthString).mkdirpSync();
|
|
} else {
|
|
await FS(basepath + monthString).mkdirp();
|
|
if (this.roomlogStream === null) return;
|
|
}
|
|
this.roomlogFilename = relpath;
|
|
if (this.roomlogStream) void this.roomlogStream.writeEnd();
|
|
this.roomlogStream = FS(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';
|
|
FS(link0).unlinkIfExistsSync();
|
|
try {
|
|
FS(link0).symlinkToSync(relpath); // intentionally a relative link
|
|
FS(link0).renameSync(basepath + 'today.txt');
|
|
} catch (e) {} // OS might not support symlinks or atomic rename
|
|
if (!Roomlogs.rollLogTimer) void Roomlogs.rollLogs();
|
|
}
|
|
add(message: string) {
|
|
this.roomlog(message);
|
|
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);
|
|
if (this.roomid.startsWith('battle-')) return true; // Don't remove messages in battle rooms to preserve evidence
|
|
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}`);
|
|
for (const [i, line] of this.log.entries()) {
|
|
if (this.parseChatLine(line)?.message.startsWith(start)) {
|
|
this.log[i] = fullMessage;
|
|
break;
|
|
}
|
|
}
|
|
this.broadcastBuffer.push(fullMessage);
|
|
}
|
|
private parseChatLine(line: string) {
|
|
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 (!this.roomlogStream) return;
|
|
const timestamp = Chat.toTimestamp(date).split(' ')[1] + ' ';
|
|
message = message.replace(/<img[^>]* src="data:image\/png;base64,[^">]+"[^>]*>/g, '');
|
|
void this.roomlogStream.write(timestamp + message + '\n');
|
|
}
|
|
modlog(entry: PartialModlogEntry, overrideID?: string) {
|
|
void Rooms.Modlog.write(this.roomid, entry, overrideID);
|
|
}
|
|
async rename(newID: RoomID): Promise<true> {
|
|
const roomlogPath = `logs/chat`;
|
|
const roomlogStreamExisted = this.roomlogStream !== null;
|
|
await this.destroy(false); // don't destroy modlog, since it's renamed later
|
|
const [roomlogExists, newRoomlogExists] = await Promise.all([
|
|
FS(roomlogPath + `/${this.roomid}`).exists(),
|
|
FS(roomlogPath + `/${newID}`).exists(),
|
|
]);
|
|
if (roomlogExists && !newRoomlogExists) {
|
|
await FS(roomlogPath + `/${this.roomid}`).rename(roomlogPath + `/${newID}`);
|
|
}
|
|
await Rooms.Modlog.rename(this.roomid, newID);
|
|
this.roomid = newID;
|
|
Roomlogs.roomlogs.set(newID, this);
|
|
if (roomlogStreamExisted) {
|
|
this.roomlogStream = undefined;
|
|
this.roomlogFilename = "";
|
|
await this.setupRoomlogStream(true);
|
|
}
|
|
return true;
|
|
}
|
|
static async rollLogs() {
|
|
if (Roomlogs.rollLogTimer === true) return;
|
|
if (Roomlogs.rollLogTimer) {
|
|
clearTimeout(Roomlogs.rollLogTimer);
|
|
}
|
|
Roomlogs.rollLogTimer = true;
|
|
for (const log of Roomlogs.roomlogs.values()) {
|
|
await log.setupRoomlogStream();
|
|
}
|
|
const time = Date.now();
|
|
const nextMidnight = new Date(time + 24 * 60 * 60 * 1000);
|
|
nextMidnight.setHours(0, 0, 1);
|
|
Roomlogs.rollLogTimer = setTimeout(() => void 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() {
|
|
return this.log.length + this.numTruncatedLines;
|
|
}
|
|
|
|
destroy(destroyModlog?: boolean) {
|
|
const promises = [];
|
|
if (this.roomlogStream) {
|
|
promises.push(this.roomlogStream.writeEnd());
|
|
this.roomlogStream = null;
|
|
}
|
|
if (destroyModlog) promises.push(Rooms.Modlog.destroy(this.roomid));
|
|
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,
|
|
|
|
rollLogs: Roomlog.rollLogs,
|
|
|
|
rollLogTimer: null as NodeJS.Timeout | true | null,
|
|
};
|