mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-03-21 17:25:10 -05:00
2404 lines
86 KiB
TypeScript
2404 lines
86 KiB
TypeScript
/**
|
||
* Chat
|
||
* Pokemon Showdown - http://pokemonshowdown.com/
|
||
*
|
||
* This handles chat and chat commands sent from users to chatrooms
|
||
* and PMs. The main function you're looking for is Chat.parse
|
||
* (scroll down to its definition for details)
|
||
*
|
||
* Individual commands are put in:
|
||
* chat-commands/ - "core" commands that shouldn't be modified
|
||
* chat-plugins/ - other commands that can be safely modified
|
||
*
|
||
* The command API is (mostly) documented in chat-plugins/COMMANDS.md
|
||
*
|
||
* @license MIT
|
||
*/
|
||
|
||
/*
|
||
|
||
To reload chat commands:
|
||
|
||
/hotpatch chat
|
||
|
||
*/
|
||
|
||
import type {RoomPermission, GlobalPermission} from './user-groups';
|
||
import type {Punishment} from './punishments';
|
||
import type {PartialModlogEntry} from './modlog';
|
||
|
||
export type PageHandler = (this: PageContext, query: string[], user: User, connection: Connection)
|
||
=> Promise<string | null | void> | string | null | void;
|
||
export interface PageTable {
|
||
[k: string]: PageHandler | PageTable;
|
||
}
|
||
|
||
export type ChatHandler = (
|
||
this: CommandContext,
|
||
target: string,
|
||
room: Room | null,
|
||
user: User,
|
||
connection: Connection,
|
||
cmd: string,
|
||
message: string
|
||
) => void;
|
||
export type AnnotatedChatHandler = ChatHandler & {
|
||
requiresRoom: boolean | RoomID,
|
||
hasRoomPermissions: boolean,
|
||
broadcastable: boolean,
|
||
cmd: string,
|
||
fullCmd: string,
|
||
isPrivate: boolean,
|
||
disabled: boolean,
|
||
aliases: string[],
|
||
};
|
||
export interface ChatCommands {
|
||
[k: string]: ChatHandler | string | string[] | ChatCommands;
|
||
}
|
||
export interface AnnotatedChatCommands {
|
||
[k: string]: AnnotatedChatHandler | string | string[] | AnnotatedChatCommands;
|
||
}
|
||
|
||
export interface ChatPlugin {
|
||
commands?: AnnotatedChatCommands;
|
||
pages?: PageTable;
|
||
destroy?: () => void;
|
||
roomSettings?: SettingsHandler | SettingsHandler[];
|
||
[k: string]: any;
|
||
}
|
||
|
||
export type SettingsHandler = (
|
||
room: Room,
|
||
user: User,
|
||
connection: Connection
|
||
) => {
|
||
label: string,
|
||
permission: boolean | RoomPermission,
|
||
// button label, command | disabled
|
||
options: [string, string | true][],
|
||
};
|
||
|
||
export type CRQHandler = (this: CommandContext, target: string, user: User, trustable?: boolean) => any;
|
||
|
||
/**
|
||
* Chat filters can choose to:
|
||
* 1. return false OR null - to not send a user's message
|
||
* 2. return an altered string - to alter a user's message
|
||
* 3. return undefined to send the original message through
|
||
*/
|
||
export type ChatFilter = ((
|
||
this: CommandContext,
|
||
message: string,
|
||
user: User,
|
||
room: Room | null,
|
||
connection: Connection,
|
||
targetUser: User | null,
|
||
originalMessage: string
|
||
) => string | false | null | undefined) & {priority?: number};
|
||
|
||
export type NameFilter = (name: string, user: User) => string;
|
||
export type NicknameFilter = (name: string, user: User) => string | false;
|
||
export type StatusFilter = (status: string, user: User) => string;
|
||
export type PunishmentFilter = (user: User | ID, punishment: Punishment) => void;
|
||
export type LoginFilter = (user: User, oldUser: User | null, userType: string) => void;
|
||
export type HostFilter = (host: string, user: User, connection: Connection, hostType: string) => void;
|
||
|
||
export interface Translations {
|
||
name?: string;
|
||
strings: {[english: string]: string};
|
||
}
|
||
|
||
const LINK_WHITELIST = [
|
||
'*.pokemonshowdown.com', 'psim.us', 'smogtours.psim.us',
|
||
'*.smogon.com', '*.pastebin.com', '*.hastebin.com',
|
||
];
|
||
|
||
const MAX_MESSAGE_LENGTH = 300;
|
||
|
||
const BROADCAST_COOLDOWN = 20 * 1000;
|
||
const MESSAGE_COOLDOWN = 5 * 60 * 1000;
|
||
|
||
const MAX_PARSE_RECURSION = 10;
|
||
|
||
const VALID_COMMAND_TOKENS = '/!';
|
||
const BROADCAST_TOKEN = '!';
|
||
|
||
import {FS, Utils} from '../lib';
|
||
import {formatText, linkRegex, stripFormatting} from './chat-formatter';
|
||
|
||
// @ts-ignore no typedef available
|
||
import ProbeModule = require('probe-image-size');
|
||
const probe: (url: string) => Promise<{width: number, height: number}> = ProbeModule;
|
||
|
||
const EMOJI_REGEX = /[\p{Emoji_Modifier_Base}\p{Emoji_Presentation}\uFE0F]/u;
|
||
const TRANSLATION_DIRECTORY = `${__dirname}/../.translations-dist`;
|
||
|
||
class PatternTester {
|
||
// This class sounds like a RegExp
|
||
// In fact, one could in theory implement it as a RegExp subclass
|
||
// However, ES2016 RegExp subclassing is a can of worms, and it wouldn't allow us
|
||
// to tailor the test method for fast command parsing.
|
||
readonly elements: string[];
|
||
readonly fastElements: Set<string>;
|
||
regexp: RegExp | null;
|
||
constructor() {
|
||
this.elements = [];
|
||
this.fastElements = new Set();
|
||
this.regexp = null;
|
||
}
|
||
fastNormalize(elem: string) {
|
||
return elem.slice(0, -1);
|
||
}
|
||
update() {
|
||
const slowElements = this.elements.filter(elem => !this.fastElements.has(this.fastNormalize(elem)));
|
||
if (slowElements.length) {
|
||
this.regexp = new RegExp('^(' + slowElements.map(elem => '(?:' + elem + ')').join('|') + ')', 'i');
|
||
}
|
||
}
|
||
register(...elems: string[]) {
|
||
for (const elem of elems) {
|
||
this.elements.push(elem);
|
||
if (/^[^ ^$?|()[\]]+ $/.test(elem)) {
|
||
this.fastElements.add(this.fastNormalize(elem));
|
||
}
|
||
}
|
||
this.update();
|
||
}
|
||
testCommand(text: string) {
|
||
const spaceIndex = text.indexOf(' ');
|
||
if (this.fastElements.has(spaceIndex >= 0 ? text.slice(0, spaceIndex) : text)) {
|
||
return true;
|
||
}
|
||
if (!this.regexp) return false;
|
||
return this.regexp.test(text);
|
||
}
|
||
test(text: string) {
|
||
if (!text.includes('\n')) return null;
|
||
if (this.testCommand(text)) return text;
|
||
// The PM matching is a huge mess, and really needs to be replaced with
|
||
// the new multiline command system soon.
|
||
const pmMatches = /^(\/(?:pm|w|whisper|msg) [^,]*, ?)(.*)/i.exec(text);
|
||
if (pmMatches && this.testCommand(pmMatches[2])) {
|
||
if (text.split('\n').every(line => line.startsWith(pmMatches[1]))) {
|
||
return text.replace(/\n\/(?:pm|w|whisper|msg) [^,]*, ?/g, '\n');
|
||
}
|
||
return text;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/*********************************************************
|
||
* Parser
|
||
*********************************************************/
|
||
|
||
/**
|
||
* An ErrorMessage will, if used in a command/page context, simply show the user
|
||
* the error, rather than logging a crash. It's used to simplify showing errors.
|
||
*
|
||
* Outside of a command/page context, it would still cause a crash.
|
||
*/
|
||
export class ErrorMessage extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'ErrorMessage';
|
||
Error.captureStackTrace(this, ErrorMessage);
|
||
}
|
||
}
|
||
|
||
export class Interruption extends Error {
|
||
constructor() {
|
||
super('');
|
||
this.name = 'Interruption';
|
||
Error.captureStackTrace(this, ErrorMessage);
|
||
}
|
||
}
|
||
|
||
// These classes need to be declared here because they aren't hoisted
|
||
export abstract class MessageContext {
|
||
readonly user: User;
|
||
language: ID | null;
|
||
recursionDepth: number;
|
||
constructor(user: User, language: ID | null = null) {
|
||
this.user = user;
|
||
this.language = language;
|
||
this.recursionDepth = 0;
|
||
}
|
||
|
||
splitOne(target: string) {
|
||
const commaIndex = target.indexOf(',');
|
||
if (commaIndex < 0) {
|
||
return [target.trim(), ''];
|
||
}
|
||
return [target.slice(0, commaIndex).trim(), target.slice(commaIndex + 1).trim()];
|
||
}
|
||
meansYes(text: string) {
|
||
switch (text.toLowerCase().trim()) {
|
||
case 'on': case 'enable': case 'yes': case 'true': case 'allow': case '1':
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
meansNo(text: string) {
|
||
switch (text.toLowerCase().trim()) {
|
||
case 'off': case 'disable': case 'no': case 'false': case 'disallow': case '0':
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
/**
|
||
* Given an array of strings (or a comma-delimited string), check the
|
||
* first and last string for a format/mod/gen. If it exists, remove
|
||
* it from the array.
|
||
*
|
||
* @returns `format` (null if no format was found), `dex` (the dex
|
||
* for the format/mod, or the default dex if none was found), and
|
||
* `targets` (the rest of the array).
|
||
*/
|
||
splitFormat(target: string | string[], atLeastOneTarget?: boolean) {
|
||
const targets = typeof target === 'string' ? target.split(',') : target;
|
||
if (!targets[0].trim()) targets.pop();
|
||
|
||
if (targets.length > (atLeastOneTarget ? 1 : 0)) {
|
||
const {dex, format, isMatch} = this.extractFormat(targets[0].trim());
|
||
if (isMatch) {
|
||
targets.shift();
|
||
return {dex, format, targets};
|
||
}
|
||
}
|
||
if (targets.length > 1) {
|
||
const {dex, format, isMatch} = this.extractFormat(targets[targets.length - 1].trim());
|
||
if (isMatch) {
|
||
targets.pop();
|
||
return {dex, format, targets};
|
||
}
|
||
}
|
||
|
||
const room = (this as any as CommandContext).room;
|
||
const {dex, format} = this.extractFormat(room?.settings.defaultFormat || room?.battle?.format);
|
||
return {dex, format, targets};
|
||
}
|
||
extractFormat(formatOrMod?: string): {dex: ModdedDex, format: Format | null, isMatch: boolean} {
|
||
if (!formatOrMod) {
|
||
return {dex: Dex.includeData(), format: null, isMatch: false};
|
||
}
|
||
|
||
const format = Dex.formats.get(formatOrMod);
|
||
if (format.exists) {
|
||
return {dex: Dex.forFormat(format), format: format, isMatch: true};
|
||
}
|
||
|
||
if (toID(formatOrMod) in Dex.dexes) {
|
||
return {dex: Dex.mod(toID(formatOrMod)).includeData(), format: null, isMatch: true};
|
||
}
|
||
|
||
return this.extractFormat();
|
||
}
|
||
tr(strings: TemplateStringsArray | string, ...keys: any[]) {
|
||
return Chat.tr(this.language, strings, ...keys);
|
||
}
|
||
}
|
||
|
||
export class PageContext extends MessageContext {
|
||
readonly connection: Connection;
|
||
room: Room | null;
|
||
pageid: string;
|
||
initialized: boolean;
|
||
title: string;
|
||
args: string[];
|
||
constructor(options: {pageid: string, user: User, connection: Connection, language?: ID}) {
|
||
super(options.user, options.language);
|
||
|
||
this.connection = options.connection;
|
||
this.room = null;
|
||
this.pageid = options.pageid;
|
||
this.args = this.pageid.split('-');
|
||
|
||
this.initialized = false;
|
||
this.title = 'Page';
|
||
}
|
||
|
||
checkCan(permission: RoomPermission, target: User | null, room: Room): boolean;
|
||
checkCan(permission: GlobalPermission, target?: User | null): boolean;
|
||
checkCan(permission: string, target: User | null = null, room: Room | null = null) {
|
||
if (!this.user.can(permission as any, target, room as any)) {
|
||
throw new Chat.ErrorMessage(`<h2>Permission denied.</h2>`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
privatelyCheckCan(permission: RoomPermission, target: User | null, room: Room): boolean;
|
||
privatelyCheckCan(permission: GlobalPermission, target?: User | null): boolean;
|
||
privatelyCheckCan(permission: string, target: User | null = null, room: Room | null = null) {
|
||
if (!this.user.can(permission as any, target, room as any)) {
|
||
this.pageDoesNotExist();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
pageDoesNotExist(): never {
|
||
throw new Chat.ErrorMessage(`Page "${this.pageid}" not found`);
|
||
}
|
||
|
||
requireRoom(pageid?: string) {
|
||
const room = this.extractRoom(pageid);
|
||
if (!room) {
|
||
throw new Chat.ErrorMessage(`Invalid link: This page requires a room ID.`);
|
||
}
|
||
|
||
this.room = room;
|
||
return room;
|
||
}
|
||
extractRoom(pageid?: string) {
|
||
if (!pageid) pageid = this.pageid;
|
||
const parts = pageid.split('-');
|
||
|
||
// The roomid for the page should be view-pagename-roomid
|
||
const room = Rooms.get(parts[2]) || null;
|
||
|
||
this.room = room;
|
||
return room;
|
||
}
|
||
|
||
setHTML(html: string) {
|
||
const roomid = this.room ? `[${this.room.roomid}] ` : '';
|
||
let content = `|title|${roomid}${this.title}\n|pagehtml|${html}`;
|
||
if (!this.initialized) {
|
||
content = `|init|html\n${content}`;
|
||
this.initialized = true;
|
||
}
|
||
this.send(content);
|
||
}
|
||
errorReply(message: string) {
|
||
this.setHTML(`<div class="pad"><p class="message-error">${message}</p></div>`);
|
||
}
|
||
|
||
send(content: string) {
|
||
this.connection.send(`>${this.pageid}\n${content}`);
|
||
}
|
||
close() {
|
||
this.send('|deinit');
|
||
}
|
||
|
||
async resolve(pageid?: string) {
|
||
if (pageid) this.pageid = pageid;
|
||
|
||
const parts = this.pageid.split('-');
|
||
parts.shift(); // first part is always `view`
|
||
|
||
if (!this.connection.openPages) this.connection.openPages = new Set();
|
||
this.connection.openPages.add(parts.join('-'));
|
||
|
||
let handler: PageHandler | PageTable = Chat.pages;
|
||
while (handler) {
|
||
if (typeof handler === 'function') {
|
||
break;
|
||
}
|
||
handler = handler[parts.shift() || 'default'];
|
||
}
|
||
|
||
this.args = parts;
|
||
|
||
let res;
|
||
try {
|
||
if (typeof handler !== 'function') this.pageDoesNotExist();
|
||
res = await handler.call(this, parts, this.user, this.connection);
|
||
} catch (err) {
|
||
if (err.name?.endsWith('ErrorMessage')) {
|
||
if (err.message) this.errorReply(err.message);
|
||
return;
|
||
}
|
||
if (err.name.endsWith('Interruption')) {
|
||
return;
|
||
}
|
||
Monitor.crashlog(err, 'A chat page', {
|
||
user: this.user.name,
|
||
room: this.room && this.room.roomid,
|
||
pageid: this.pageid,
|
||
});
|
||
this.setHTML(
|
||
`<div class="pad"><div class="broadcast-red">` +
|
||
`<strong>Pokemon Showdown crashed!</strong><br />Don't worry, we're working on fixing it.` +
|
||
`</div></div>`
|
||
);
|
||
}
|
||
if (typeof res === 'string') {
|
||
this.setHTML(res);
|
||
res = undefined;
|
||
}
|
||
return res;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This is a message sent in a PM or to a chat/battle room.
|
||
*
|
||
* There are three cases to be aware of:
|
||
* - PM to user: `context.pmTarget` will exist and `context.room` will be `null`
|
||
* - message to room: `context.room` will exist and `context.pmTarget` will be `null`
|
||
* - console command (PM to `~`): `context.pmTarget` and `context.room` will both be `null`
|
||
*/
|
||
export class CommandContext extends MessageContext {
|
||
message: string;
|
||
pmTarget: User | null;
|
||
room: Room | null;
|
||
connection: Connection;
|
||
|
||
cmd: string;
|
||
cmdToken: string;
|
||
target: string;
|
||
fullCmd: string;
|
||
handler: AnnotatedChatHandler | null;
|
||
|
||
isQuiet: boolean;
|
||
broadcasting: boolean;
|
||
broadcastToRoom: boolean;
|
||
broadcastMessage: string;
|
||
targetUser: User | null;
|
||
targetUsername: string;
|
||
inputUsername: string;
|
||
constructor(
|
||
options:
|
||
{message: string, user: User, connection: Connection} &
|
||
Partial<{room: Room | null, pmTarget: User | null, cmd: string, cmdToken: string, target: string, fullCmd: string}>
|
||
) {
|
||
super(
|
||
options.user, options.room && options.room.settings.language ?
|
||
options.room.settings.language : options.user.language
|
||
);
|
||
|
||
this.message = options.message || ``;
|
||
|
||
// message context
|
||
this.pmTarget = options.pmTarget || null;
|
||
this.room = options.room || null;
|
||
this.connection = options.connection;
|
||
|
||
// command context
|
||
this.cmd = options.cmd || '';
|
||
this.cmdToken = options.cmdToken || '';
|
||
this.target = options.target || ``;
|
||
this.fullCmd = options.fullCmd || '';
|
||
this.handler = null;
|
||
this.isQuiet = false;
|
||
|
||
// broadcast context
|
||
this.broadcasting = false;
|
||
this.broadcastToRoom = true;
|
||
this.broadcastMessage = '';
|
||
|
||
// target user
|
||
this.targetUser = null;
|
||
this.targetUsername = "";
|
||
this.inputUsername = "";
|
||
}
|
||
|
||
// TODO: return should be void | boolean | Promise<void | boolean>
|
||
parse(msg?: string, quiet?: boolean): any {
|
||
if (typeof msg === 'string') {
|
||
// spawn subcontext
|
||
const subcontext = new CommandContext(this);
|
||
if (quiet) subcontext.isQuiet = true;
|
||
subcontext.recursionDepth++;
|
||
if (subcontext.recursionDepth > MAX_PARSE_RECURSION) {
|
||
throw new Error("Too much command recursion");
|
||
}
|
||
subcontext.message = msg;
|
||
subcontext.cmd = '';
|
||
subcontext.fullCmd = '';
|
||
subcontext.cmdToken = '';
|
||
subcontext.target = '';
|
||
return subcontext.parse();
|
||
}
|
||
let message: string | void | boolean | Promise<string | void | boolean> = this.message;
|
||
|
||
const parsedCommand = Chat.parseCommand(message);
|
||
if (parsedCommand) {
|
||
this.cmd = parsedCommand.cmd;
|
||
this.fullCmd = parsedCommand.fullCmd;
|
||
this.cmdToken = parsedCommand.cmdToken;
|
||
this.target = parsedCommand.target;
|
||
this.handler = parsedCommand.handler;
|
||
}
|
||
|
||
if (this.room && !(this.user.id in this.room.users)) {
|
||
if (this.room.roomid === 'lobby') {
|
||
this.room = null;
|
||
} else {
|
||
return this.popupReply(`You tried to send "${message}" to the room "${this.room.roomid}" but it failed because you were not in that room.`);
|
||
}
|
||
}
|
||
|
||
if (this.user.statusType === 'idle' && !['unaway', 'unafk', 'back'].includes(this.cmd)) {
|
||
this.user.setStatusType('online');
|
||
}
|
||
|
||
try {
|
||
if (this.handler) {
|
||
if (this.handler.disabled) {
|
||
throw new Chat.ErrorMessage(
|
||
`The command /${this.cmd} is temporarily unavailable due to technical difficulties. Please try again in a few hours.`
|
||
);
|
||
}
|
||
message = this.run(this.handler);
|
||
} else {
|
||
if (this.cmdToken) {
|
||
// To guard against command typos, show an error message
|
||
if (!(this.shouldBroadcast() && !/[a-z0-9]/.test(this.cmd.charAt(0)))) {
|
||
this.commandDoesNotExist();
|
||
}
|
||
} else if (!VALID_COMMAND_TOKENS.includes(message.charAt(0)) &&
|
||
VALID_COMMAND_TOKENS.includes(message.trim().charAt(0))) {
|
||
message = message.trim();
|
||
if (!message.startsWith(BROADCAST_TOKEN)) {
|
||
message = message.charAt(0) + message;
|
||
}
|
||
}
|
||
|
||
message = this.checkChat(message);
|
||
}
|
||
} catch (err) {
|
||
if (err.name?.endsWith('ErrorMessage')) {
|
||
this.errorReply(err.message);
|
||
this.update();
|
||
return false;
|
||
}
|
||
if (err.name.endsWith('Interruption')) {
|
||
this.update();
|
||
return;
|
||
}
|
||
Monitor.crashlog(err, 'A chat command', {
|
||
user: this.user.name,
|
||
room: this.room?.roomid,
|
||
pmTarget: this.pmTarget?.name,
|
||
message: this.message,
|
||
});
|
||
this.sendReply(`|html|<div class="broadcast-red"><b>Pokemon Showdown crashed!</b><br />Don't worry, we're working on fixing it.</div>`);
|
||
return;
|
||
}
|
||
|
||
// Output the message
|
||
if (message && typeof (message as any).then === 'function') {
|
||
this.update();
|
||
return (message as Promise<string | boolean | void>).then(resolvedMessage => {
|
||
if (resolvedMessage && resolvedMessage !== true) {
|
||
this.sendChatMessage(resolvedMessage);
|
||
}
|
||
this.update();
|
||
if (resolvedMessage === false) return false;
|
||
}).catch(err => {
|
||
if (err.name?.endsWith('ErrorMessage')) {
|
||
this.errorReply(err.message);
|
||
this.update();
|
||
return false;
|
||
}
|
||
if (err.name.endsWith('Interruption')) {
|
||
this.update();
|
||
return;
|
||
}
|
||
Monitor.crashlog(err, 'An async chat command', {
|
||
user: this.user.name,
|
||
room: this.room?.roomid,
|
||
pmTarget: this.pmTarget?.name,
|
||
message: this.message,
|
||
});
|
||
this.sendReply(`|html|<div class="broadcast-red"><b>Pokemon Showdown crashed!</b><br />Don't worry, we're working on fixing it.</div>`);
|
||
return false;
|
||
});
|
||
} else if (message && message !== true) {
|
||
this.sendChatMessage(message as string);
|
||
message = true;
|
||
}
|
||
|
||
this.update();
|
||
|
||
return message;
|
||
}
|
||
|
||
sendChatMessage(message: string) {
|
||
if (this.pmTarget) {
|
||
const blockInvites = this.pmTarget.settings.blockInvites;
|
||
if (blockInvites && /^<<.*>>$/.test(message.trim())) {
|
||
if (
|
||
!this.user.can('lock') && blockInvites === true ||
|
||
!Users.globalAuth.atLeast(this.user, blockInvites as GroupSymbol)
|
||
) {
|
||
Chat.maybeNotifyBlocked(`invite`, this.pmTarget, this.user);
|
||
return this.errorReply(`${this.pmTarget.name} is blocking room invites.`);
|
||
}
|
||
}
|
||
Chat.sendPM(message, this.user, this.pmTarget);
|
||
} else if (this.room) {
|
||
this.room.add(`|c|${this.user.getIdentity(this.room.roomid)}|${message}`);
|
||
if (this.room.game && this.room.game.onLogMessage) {
|
||
this.room.game.onLogMessage(message, this.user);
|
||
}
|
||
} else {
|
||
this.connection.popup(`Your message could not be sent:\n\n${message}\n\nIt needs to be sent to a user or room.`);
|
||
}
|
||
}
|
||
run(handler: string | AnnotatedChatHandler) {
|
||
if (typeof handler === 'string') handler = Chat.commands[handler] as AnnotatedChatHandler;
|
||
if (!handler.broadcastable && this.cmdToken === '!') {
|
||
this.errorReply(`The command "${this.fullCmd}" can't be broadcast.`);
|
||
this.errorReply(`Use /${this.fullCmd} instead.`);
|
||
return false;
|
||
}
|
||
let result: any = handler.call(this, this.target, this.room, this.user, this.connection, this.cmd, this.message);
|
||
if (result === undefined) result = false;
|
||
|
||
return result;
|
||
}
|
||
|
||
checkFormat(room: BasicRoom | null | undefined, user: User, message: string) {
|
||
if (!room) return true;
|
||
if (!room.settings.filterStretching && !room.settings.filterCaps && !room.settings.filterEmojis) return true;
|
||
if (user.can('bypassall')) return true;
|
||
|
||
if (room.settings.filterStretching && /(.+?)\1{5,}/i.test(user.name)) {
|
||
throw new Chat.ErrorMessage(`Your username contains too much stretching, which this room doesn't allow.`);
|
||
}
|
||
if (room.settings.filterCaps && /[A-Z\s]{6,}/.test(user.name)) {
|
||
throw new Chat.ErrorMessage(`Your username contains too many capital letters, which this room doesn't allow.`);
|
||
}
|
||
if (room.settings.filterEmojis && EMOJI_REGEX.test(user.name)) {
|
||
throw new Chat.ErrorMessage(`Your username contains emojis, which this room doesn't allow.`);
|
||
}
|
||
// Removes extra spaces and null characters
|
||
message = message.trim().replace(/[ \u0000\u200B-\u200F]+/g, ' ');
|
||
|
||
if (room.settings.filterStretching && /(.+?)\1{7,}/i.test(message)) {
|
||
throw new Chat.ErrorMessage(`Your message contains too much stretching, which this room doesn't allow.`);
|
||
}
|
||
if (room.settings.filterCaps && /[A-Z\s]{18,}/.test(message)) {
|
||
throw new Chat.ErrorMessage(`Your message contains too many capital letters, which this room doesn't allow.`);
|
||
}
|
||
if (room.settings.filterEmojis && EMOJI_REGEX.test(message)) {
|
||
throw new Chat.ErrorMessage(`Your message contains emojis, which this room doesn't allow.`);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
checkSlowchat(room: Room | null | undefined, user: User) {
|
||
if (!room?.settings.slowchat) return true;
|
||
if (user.can('show', null, room)) return true;
|
||
const lastActiveSeconds = (Date.now() - user.lastMessageTime) / 1000;
|
||
if (lastActiveSeconds < room.settings.slowchat) {
|
||
throw new Chat.ErrorMessage(this.tr`This room has slow-chat enabled. You can only talk once every ${room.settings.slowchat} seconds.`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
checkBanwords(room: BasicRoom | null | undefined, message: string): boolean {
|
||
if (!room) return true;
|
||
if (!room.banwordRegex) {
|
||
if (room.settings.banwords && room.settings.banwords.length) {
|
||
room.banwordRegex = new RegExp('(?:\\b|(?!\\w))(?:' + room.settings.banwords.join('|') + ')(?:\\b|\\B(?!\\w))', 'i');
|
||
} else {
|
||
room.banwordRegex = true;
|
||
}
|
||
}
|
||
if (!message) return true;
|
||
if (room.banwordRegex !== true && room.banwordRegex.test(message)) {
|
||
throw new Chat.ErrorMessage(`Your username, status, or message contained a word banned by this room.`);
|
||
}
|
||
return this.checkBanwords(room.parent as ChatRoom, message);
|
||
}
|
||
checkGameFilter() {
|
||
if (!this.room?.game || !this.room.game.onChatMessage) return;
|
||
return this.room.game.onChatMessage(this.message, this.user);
|
||
}
|
||
pmTransform(originalMessage: string) {
|
||
if (this.room) throw new Error(`Not a PM`);
|
||
const targetIdentity = this.pmTarget ? this.pmTarget.getIdentity() : '~';
|
||
const prefix = `|pm|${this.user.getIdentity()}|${targetIdentity}|`;
|
||
return originalMessage.split('\n').map(message => {
|
||
if (message.startsWith('||')) {
|
||
return prefix + `/text ` + message.slice(2);
|
||
} else if (message.startsWith(`|html|`)) {
|
||
return prefix + `/raw ` + message.slice(6);
|
||
} else if (message.startsWith(`|modaction|`)) {
|
||
return prefix + `/log ` + message.slice(11);
|
||
} else if (message.startsWith(`|raw|`)) {
|
||
return prefix + `/raw ` + message.slice(5);
|
||
} else if (message.startsWith(`|error|`)) {
|
||
return prefix + `/error ` + message.slice(7);
|
||
} else if (message.startsWith(`|c~|`)) {
|
||
return prefix + message.slice(4);
|
||
} else if (message.startsWith(`|c|~|/`)) {
|
||
return prefix + message.slice(5);
|
||
} else if (message.startsWith(`|c|~|`)) {
|
||
return prefix + `/text ` + message.slice(5);
|
||
}
|
||
return prefix + `/text ` + message;
|
||
}).join(`\n`);
|
||
}
|
||
sendReply(data: string) {
|
||
if (this.isQuiet) return;
|
||
if (this.broadcasting && this.broadcastToRoom) {
|
||
// broadcasting
|
||
this.add(data);
|
||
} else {
|
||
// not broadcasting
|
||
if (!this.room) {
|
||
data = this.pmTransform(data);
|
||
this.connection.send(data);
|
||
} else {
|
||
this.connection.sendTo(this.room, data);
|
||
}
|
||
}
|
||
}
|
||
errorReply(message: string) {
|
||
this.sendReply(`|error|` + message.replace(/\n/g, `\n|error|`));
|
||
}
|
||
addBox(htmlContent: string) {
|
||
this.add(`|html|<div class="infobox">${htmlContent}</div>`);
|
||
}
|
||
sendReplyBox(htmlContent: string) {
|
||
this.sendReply(`|c|${this.room && this.broadcasting ? this.user.getIdentity() : '~'}|/raw <div class="infobox">${htmlContent}</div>`);
|
||
}
|
||
popupReply(message: string) {
|
||
this.connection.popup(message);
|
||
}
|
||
add(data: string) {
|
||
if (this.room) {
|
||
this.room.add(data);
|
||
} else {
|
||
this.send(data);
|
||
}
|
||
}
|
||
send(data: string) {
|
||
if (this.room) {
|
||
this.room.send(data);
|
||
} else {
|
||
data = this.pmTransform(data);
|
||
this.user.send(data);
|
||
if (this.pmTarget && this.pmTarget !== this.user) {
|
||
this.pmTarget.send(data);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** like privateModAction, but also notify Staff room */
|
||
privateGlobalModAction(msg: string) {
|
||
this.privateModAction(msg);
|
||
if (this.room?.roomid !== 'staff') {
|
||
Rooms.get('staff')?.addByUser(this.user, `${this.room ? `<<${this.room.roomid}>>` : `<PM:${this.pmTarget}>`} ${msg}`).update();
|
||
}
|
||
}
|
||
addGlobalModAction(msg: string) {
|
||
this.addModAction(msg);
|
||
if (this.room?.roomid !== 'staff') {
|
||
Rooms.get('staff')?.addByUser(this.user, `${this.room ? `<<${this.room.roomid}>>` : `<PM:${this.pmTarget}>`} ${msg}`).update();
|
||
}
|
||
}
|
||
|
||
privateModAction(msg: string) {
|
||
if (this.room) {
|
||
if (this.room.roomid === 'staff') {
|
||
this.room.addByUser(this.user, `(${msg})`);
|
||
} else {
|
||
this.room.sendModsByUser(this.user, `(${msg})`);
|
||
}
|
||
} else {
|
||
const data = this.pmTransform(`|modaction|${msg}`);
|
||
this.user.send(data);
|
||
if (this.pmTarget && this.pmTarget !== this.user && this.pmTarget.isStaff) {
|
||
this.pmTarget.send(data);
|
||
}
|
||
}
|
||
this.roomlog(`(${msg})`);
|
||
}
|
||
globalModlog(action: string, user: string | User | null = null, note: string | null = null, ip?: string) {
|
||
const entry: PartialModlogEntry = {
|
||
action,
|
||
isGlobal: true,
|
||
loggedBy: this.user.id,
|
||
note: note?.replace(/\n/gm, ' ') || '',
|
||
};
|
||
if (user) {
|
||
if (typeof user === 'string') {
|
||
entry.userid = toID(user);
|
||
} else {
|
||
entry.ip = user.latestIp;
|
||
const userid = user.getLastId();
|
||
entry.userid = userid;
|
||
if (user.autoconfirmed && user.autoconfirmed !== userid) entry.autoconfirmedID = user.autoconfirmed;
|
||
const alts = user.getAltUsers(false, true).slice(1).map(alt => alt.getLastId());
|
||
if (alts.length) entry.alts = alts;
|
||
}
|
||
}
|
||
if (ip) entry.ip = ip;
|
||
if (this.room) {
|
||
this.room.modlog(entry);
|
||
} else {
|
||
Rooms.global.modlog(entry);
|
||
}
|
||
}
|
||
modlog(
|
||
action: string,
|
||
user: string | User | null = null,
|
||
note: string | null = null,
|
||
options: Partial<{noalts: any, noip: any}> = {}
|
||
) {
|
||
const entry: PartialModlogEntry = {
|
||
action,
|
||
loggedBy: this.user.id,
|
||
note: note?.replace(/\n/gm, ' ') || '',
|
||
};
|
||
if (user) {
|
||
if (typeof user === 'string') {
|
||
entry.userid = toID(user);
|
||
} else {
|
||
const userid = user.getLastId();
|
||
entry.userid = userid;
|
||
if (!options.noalts) {
|
||
if (user.autoconfirmed && user.autoconfirmed !== userid) entry.autoconfirmedID = user.autoconfirmed;
|
||
const alts = user.getAltUsers(false, true).slice(1).map(alt => alt.getLastId());
|
||
if (alts.length) entry.alts = alts;
|
||
}
|
||
if (!options.noip) entry.ip = user.latestIp;
|
||
}
|
||
}
|
||
(this.room || Rooms.global).modlog(entry);
|
||
}
|
||
parseSpoiler(str: string) {
|
||
let privateReason = "";
|
||
if (!str) return {publicReason: "", privateReason};
|
||
|
||
let publicReason = str;
|
||
const targetLowercase = str.toLowerCase();
|
||
if (targetLowercase.includes('spoiler:') || targetLowercase.includes('spoilers:')) {
|
||
const proofIndex = targetLowercase.indexOf(targetLowercase.includes('spoilers:') ? 'spoilers:' : 'spoiler:');
|
||
const bump = (targetLowercase.includes('spoilers:') ? 9 : 8);
|
||
privateReason = `(PROOF: ${str.substr(proofIndex + bump, str.length).trim()}) `;
|
||
publicReason = str.substr(0, proofIndex).trim();
|
||
}
|
||
return {publicReason, privateReason};
|
||
}
|
||
roomlog(data: string) {
|
||
if (this.room) this.room.roomlog(data);
|
||
}
|
||
stafflog(data: string) {
|
||
(Rooms.get('staff') || Rooms.lobby || this.room)?.roomlog(data);
|
||
}
|
||
addModAction(msg: string) {
|
||
if (this.room) {
|
||
this.room.addByUser(this.user, msg);
|
||
} else {
|
||
this.send(`|modaction|${msg}`);
|
||
}
|
||
}
|
||
update() {
|
||
if (this.room) this.room.update();
|
||
}
|
||
filter(message: string) {
|
||
return Chat.filter(message, this);
|
||
}
|
||
statusfilter(status: string) {
|
||
return Chat.statusfilter(status, this.user);
|
||
}
|
||
checkCan(permission: RoomPermission, target: User | ID | null, room: Room): undefined;
|
||
checkCan(permission: GlobalPermission, target?: User | ID | null): undefined;
|
||
checkCan(permission: string, target: User | ID | null = null, room: Room | null = null) {
|
||
if (!Users.Auth.hasPermission(this.user, permission, target, room, this.fullCmd)) {
|
||
throw new Chat.ErrorMessage(`${this.cmdToken}${this.fullCmd} - Access denied.`);
|
||
}
|
||
}
|
||
privatelyCheckCan(permission: RoomPermission, target: User | ID | null, room: Room): boolean;
|
||
privatelyCheckCan(permission: GlobalPermission, target?: User | ID | null): boolean;
|
||
privatelyCheckCan(permission: string, target: User | ID | null = null, room: Room | null = null) {
|
||
this.handler!.isPrivate = true;
|
||
if (Users.Auth.hasPermission(this.user, permission, target, room, this.fullCmd)) {
|
||
return true;
|
||
}
|
||
this.commandDoesNotExist();
|
||
}
|
||
canUseConsole() {
|
||
if (!this.user.hasConsoleAccess(this.connection)) {
|
||
throw new Chat.ErrorMessage(
|
||
this.cmdToken + this.fullCmd + " - Requires console access, please set up `Config.consoleips`."
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
shouldBroadcast() {
|
||
return this.cmdToken === BROADCAST_TOKEN;
|
||
}
|
||
checkBroadcast(ignoreCooldown?: boolean, suppressMessage?: string | null) {
|
||
if (this.broadcasting || !this.shouldBroadcast()) {
|
||
return true;
|
||
}
|
||
|
||
if (this.room && !this.user.can('show', null, this.room)) {
|
||
this.errorReply(`You need to be voiced to broadcast this command's information.`);
|
||
throw new Chat.ErrorMessage(`To see it for yourself, use: /${this.message.slice(1)}`);
|
||
}
|
||
|
||
if (!this.room && !this.pmTarget) {
|
||
this.errorReply(`Broadcasting a command with "!" in a PM or chatroom will show it that user or room.`);
|
||
throw new Chat.ErrorMessage(`To see it for yourself, use: /${this.message.slice(1)}`);
|
||
}
|
||
|
||
// broadcast cooldown
|
||
const broadcastMessage = (suppressMessage || this.message).toLowerCase().replace(/[^a-z0-9\s!,]/g, '');
|
||
|
||
if (!ignoreCooldown && this.room && this.room.lastBroadcast === broadcastMessage &&
|
||
this.room.lastBroadcastTime >= Date.now() - BROADCAST_COOLDOWN &&
|
||
!this.user.can('bypassall')) {
|
||
throw new Chat.ErrorMessage("You can't broadcast this because it was just broadcasted.");
|
||
}
|
||
|
||
const message = this.checkChat(suppressMessage || this.message);
|
||
if (!message) {
|
||
throw new Chat.ErrorMessage(`To see it for yourself, use: /${this.message.slice(1)}`);
|
||
}
|
||
|
||
// checkChat will only return true with no message
|
||
this.message = message;
|
||
this.broadcastMessage = broadcastMessage;
|
||
return true;
|
||
}
|
||
runBroadcast(ignoreCooldown = false, suppressMessage: string | null = null) {
|
||
if (this.broadcasting || !this.shouldBroadcast()) {
|
||
// Already being broadcast, or the user doesn't intend to broadcast.
|
||
return true;
|
||
}
|
||
|
||
if (!this.broadcastMessage) {
|
||
// Permission hasn't been checked yet. Do it now.
|
||
this.checkBroadcast(ignoreCooldown, suppressMessage);
|
||
}
|
||
|
||
this.broadcasting = true;
|
||
|
||
if (this.pmTarget) {
|
||
this.sendReply('|c~|' + (suppressMessage || this.message));
|
||
} else {
|
||
this.sendReply('|c|' + this.user.getIdentity(this.room ? this.room.roomid : '') + '|' + (suppressMessage || this.message));
|
||
}
|
||
if (this.room) {
|
||
// We don't want broadcasted messages in a room to be translated
|
||
// according to a user's personal language setting.
|
||
this.language = this.room.settings.language || null;
|
||
if (!ignoreCooldown) {
|
||
this.room.lastBroadcast = this.broadcastMessage;
|
||
this.room.lastBroadcastTime = Date.now();
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
/* The sucrase transformation of optional chaining is too expensive to be used in a hot function like this. */
|
||
/* eslint-disable @typescript-eslint/prefer-optional-chain */
|
||
checkChat(message: string, room?: Room | null, targetUser?: User | null): string;
|
||
checkChat(message?: null, room?: Room | null, targetUser?: User | null): void;
|
||
checkChat(message: string | null = null, room: Room | null = null, targetUser: User | null = null) {
|
||
if (!targetUser && this.pmTarget) {
|
||
targetUser = this.pmTarget;
|
||
}
|
||
if (targetUser) {
|
||
room = null;
|
||
} else if (!room) {
|
||
// @ts-ignore excludes GlobalRoom above
|
||
room = this.room;
|
||
}
|
||
const user = this.user;
|
||
const connection = this.connection;
|
||
|
||
if (!user.named) {
|
||
throw new Chat.ErrorMessage(this.tr`You must choose a name before you can talk.`);
|
||
}
|
||
if (!user.can('bypassall')) {
|
||
const lockType = (user.namelocked ? this.tr`namelocked` : user.locked ? this.tr`locked` : ``);
|
||
const lockExpiration = Punishments.checkLockExpiration(user.namelocked || user.locked);
|
||
if (room) {
|
||
if (lockType && !room.settings.isHelp) {
|
||
this.sendReply(`|html|<a href="view-help-request--appeal" class="button">${this.tr`Get help with this`}</a>`);
|
||
throw new Chat.ErrorMessage(this.tr`You are ${lockType} and can't talk in chat. ${lockExpiration}`);
|
||
}
|
||
if (room.isMuted(user)) {
|
||
throw new Chat.ErrorMessage(this.tr`You are muted and cannot talk in this room.`);
|
||
}
|
||
if (room.settings.modchat && !room.auth.atLeast(user, room.settings.modchat)) {
|
||
if (room.settings.modchat === 'autoconfirmed') {
|
||
throw new Chat.ErrorMessage(
|
||
this.tr`Because moderated chat is set, your account must be at least one week old and you must have won at least one ladder game to speak in this room.`
|
||
);
|
||
}
|
||
if (room.settings.modchat === 'trusted') {
|
||
throw new Chat.ErrorMessage(
|
||
this.tr`Because moderated chat is set, your account must be staff in a public room or have a global rank to speak in this room.`
|
||
);
|
||
}
|
||
const groupName = Config.groups[room.settings.modchat] && Config.groups[room.settings.modchat].name ||
|
||
room.settings.modchat;
|
||
throw new Chat.ErrorMessage(
|
||
this.tr`Because moderated chat is set, you must be of rank ${groupName} or higher to speak in this room.`
|
||
);
|
||
}
|
||
if (!(user.id in room.users)) {
|
||
connection.popup(`You can't send a message to this room without being in it.`);
|
||
return null;
|
||
}
|
||
}
|
||
if (targetUser) {
|
||
if (lockType && !targetUser.can('lock')) {
|
||
this.sendReply(`|html|<a href="view-help-request--appeal" class="button">${this.tr`Get help with this`}</a>`);
|
||
throw new Chat.ErrorMessage(this.tr`You are ${lockType} and can only private message members of the global moderation team. ${lockExpiration}`);
|
||
}
|
||
if (targetUser.locked && !user.can('lock')) {
|
||
throw new Chat.ErrorMessage(this.tr`The user "${targetUser.name}" is locked and cannot be PMed.`);
|
||
}
|
||
if (Config.pmmodchat && !Users.globalAuth.atLeast(user, Config.pmmodchat) &&
|
||
!Users.Auth.hasPermission(targetUser, 'promote', Config.pmmodchat as GroupSymbol)) {
|
||
const groupName = Config.groups[Config.pmmodchat] && Config.groups[Config.pmmodchat].name || Config.pmmodchat;
|
||
throw new Chat.ErrorMessage(this.tr`On this server, you must be of rank ${groupName} or higher to PM users.`);
|
||
}
|
||
if (targetUser.settings.blockPMs &&
|
||
(targetUser.settings.blockPMs === true || !Users.globalAuth.atLeast(user, targetUser.settings.blockPMs)) &&
|
||
!user.can('lock') && targetUser.id !== user.id) {
|
||
Chat.maybeNotifyBlocked('pm', targetUser, user);
|
||
if (!targetUser.can('lock')) {
|
||
throw new Chat.ErrorMessage(this.tr`This user is blocking private messages right now.`);
|
||
} else {
|
||
this.sendReply(`|html|${this.tr`If you need help, try opening a <a href="view-help-request" class="button">help ticket</a>`}`);
|
||
throw new Chat.ErrorMessage(this.tr`This ${Config.groups[targetUser.tempGroup].name} is too busy to answer private messages right now. Please contact a different staff member.`);
|
||
}
|
||
}
|
||
if (user.settings.blockPMs && (user.settings.blockPMs === true ||
|
||
!Users.globalAuth.atLeast(targetUser, user.settings.blockPMs)) && !targetUser.can('lock') &&
|
||
targetUser.id !== user.id) {
|
||
throw new Chat.ErrorMessage(this.tr`You are blocking private messages right now.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (typeof message !== 'string') return true;
|
||
|
||
if (!message) {
|
||
throw new Chat.ErrorMessage(this.tr`Your message can't be blank.`);
|
||
}
|
||
let length = message.length;
|
||
length += 10 * message.replace(/[^\ufdfd]*/g, '').length;
|
||
if (length > MAX_MESSAGE_LENGTH && !user.can('ignorelimits')) {
|
||
throw new Chat.ErrorMessage(this.tr`Your message is too long: ` + message);
|
||
}
|
||
|
||
// remove zalgo
|
||
// eslint-disable-next-line max-len
|
||
message = message.replace(/[\u0300-\u036f\u0483-\u0489\u0610-\u0615\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06ED\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]{3,}/g, '');
|
||
if (/[\u115f\u1160\u239b-\u23b9]/.test(message)) {
|
||
throw new Chat.ErrorMessage(this.tr`Your message contains banned characters.`);
|
||
}
|
||
|
||
// If the corresponding config option is set, non-AC users cannot send links, except to staff.
|
||
if (Config.restrictLinks && !user.autoconfirmed) {
|
||
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
|
||
const links = message.match(Chat.linkRegex);
|
||
const allLinksWhitelisted = !links || links.every(link => {
|
||
link = link.toLowerCase();
|
||
const domainMatches = /^(?:http:\/\/|https:\/\/)?(?:[^/]*\.)?([^/.]*\.[^/.]*)\.?($|\/|:)/.exec(link);
|
||
const domain = domainMatches?.[1];
|
||
const hostMatches = /^(?:http:\/\/|https:\/\/)?([^/]*[^/.])\.?($|\/|:)/.exec(link);
|
||
let host = hostMatches?.[1];
|
||
if (host?.startsWith('www.')) host = host.slice(4);
|
||
if (!domain || !host) return null;
|
||
return LINK_WHITELIST.includes(host) || LINK_WHITELIST.includes(`*.${domain}`);
|
||
});
|
||
if (!allLinksWhitelisted && !(targetUser?.can('lock') || room?.settings.isHelp)) {
|
||
throw new Chat.ErrorMessage("Your account must be autoconfirmed to send links to other users, except for global staff.");
|
||
}
|
||
}
|
||
|
||
this.checkFormat(room, user, message);
|
||
|
||
this.checkSlowchat(room, user);
|
||
|
||
if (!user.can('bypassall')) this.checkBanwords(room, user.name);
|
||
if (user.userMessage && !user.can('bypassall')) this.checkBanwords(room, user.userMessage);
|
||
if (room && !user.can('mute', null, room)) this.checkBanwords(room, message);
|
||
|
||
const gameFilter = this.checkGameFilter();
|
||
if (typeof gameFilter === 'string') {
|
||
if (gameFilter === '') throw new Chat.Interruption();
|
||
throw new Chat.ErrorMessage(gameFilter);
|
||
}
|
||
|
||
if (room) {
|
||
const normalized = message.trim();
|
||
if (
|
||
!user.can('bypassall') && (['help', 'lobby'].includes(room.roomid)) && (normalized === user.lastMessage) &&
|
||
((Date.now() - user.lastMessageTime) < MESSAGE_COOLDOWN) && !Config.nothrottle
|
||
) {
|
||
throw new Chat.ErrorMessage(this.tr`You can't send the same message again so soon.`);
|
||
}
|
||
user.lastMessage = message;
|
||
user.lastMessageTime = Date.now();
|
||
}
|
||
|
||
if (room?.settings.highTraffic &&
|
||
toID(message).replace(/[^a-z]+/, '').length < 2 &&
|
||
!user.can('show', null, room)) {
|
||
throw new Chat.ErrorMessage(
|
||
this.tr`Due to this room being a high traffic room, your message must contain at least two letters.`
|
||
);
|
||
}
|
||
|
||
if (Chat.filters.length) {
|
||
return this.filter(message);
|
||
}
|
||
|
||
return message;
|
||
}
|
||
checkPMHTML(targetUser: User | null) {
|
||
if (!targetUser?.connected) {
|
||
throw new Chat.ErrorMessage(`User ${this.targetUsername} is not currently online.`);
|
||
}
|
||
if (!(this.room && (targetUser.id in this.room.users)) && !this.user.can('addhtml')) {
|
||
throw new Chat.ErrorMessage("You do not have permission to use PM HTML to users who are not in this room.");
|
||
}
|
||
if (targetUser.settings.blockPMs &&
|
||
(targetUser.settings.blockPMs === true || !Users.globalAuth.atLeast(this.user, targetUser.settings.blockPMs)) &&
|
||
!this.user.can('lock')
|
||
) {
|
||
Chat.maybeNotifyBlocked('pm', targetUser, this.user);
|
||
throw new Chat.ErrorMessage("This user is currently blocking PMs.");
|
||
}
|
||
if (targetUser.locked && !this.user.can('lock')) {
|
||
throw new Chat.ErrorMessage("This user is currently locked, so you cannot send them HTML.");
|
||
}
|
||
return true;
|
||
}
|
||
/* eslint-enable @typescript-eslint/prefer-optional-chain */
|
||
checkEmbedURI(uri: string, autofix?: boolean) {
|
||
if (uri.startsWith('https://')) return uri;
|
||
if (uri.startsWith('//')) return uri;
|
||
if (uri.startsWith('data:')) return uri;
|
||
if (!uri.startsWith('http://')) {
|
||
if (/^[a-z]+:\/\//.test(uri)) {
|
||
throw new Chat.ErrorMessage("Image URLs must begin with 'https://' or 'http://' or 'data:'");
|
||
}
|
||
} else {
|
||
uri = uri.slice(7);
|
||
}
|
||
const slashIndex = uri.indexOf('/');
|
||
let domain = (slashIndex >= 0 ? uri.slice(0, slashIndex) : uri);
|
||
|
||
// heuristic that works for all the domains we care about
|
||
const secondLastDotIndex = domain.lastIndexOf('.', domain.length - 5);
|
||
if (secondLastDotIndex >= 0) domain = domain.slice(secondLastDotIndex + 1);
|
||
|
||
const approvedDomains = [
|
||
'imgur.com',
|
||
'gyazo.com',
|
||
'puu.sh',
|
||
'rotmgtool.com',
|
||
'pokemonshowdown.com',
|
||
'nocookie.net',
|
||
'blogspot.com',
|
||
'imageshack.us',
|
||
'deviantart.net',
|
||
'd.pr',
|
||
'pokefans.net',
|
||
];
|
||
if (approvedDomains.includes(domain)) {
|
||
if (autofix) return `//${uri}`;
|
||
throw new Chat.ErrorMessage(`Please use HTTPS for image "${uri}"`);
|
||
}
|
||
if (domain === 'bit.ly') {
|
||
throw new Chat.ErrorMessage("Please don't use URL shorteners.");
|
||
}
|
||
// unknown URI, allow HTTP to be safe
|
||
return uri;
|
||
}
|
||
/**
|
||
* This is a quick and dirty first-pass "is this good HTML" check. The full
|
||
* sanitization is done on the client by Caja in `src/battle-log.ts`
|
||
* `BattleLog.sanitizeHTML`.
|
||
*/
|
||
checkHTML(htmlContent: string | null) {
|
||
htmlContent = ('' + (htmlContent || '')).trim();
|
||
if (!htmlContent) return '';
|
||
if (/>here.?</i.test(htmlContent) || /click here/i.test(htmlContent)) {
|
||
throw new Chat.ErrorMessage('Do not use "click here" – See [[Design standard #2 <https://github.com/smogon/pokemon-showdown/blob/master/CONTRIBUTING.md#design-standards>]]');
|
||
}
|
||
|
||
// check for mismatched tags
|
||
const tags = htmlContent.match(/<!--.*?-->|<\/?[^<>]*/g);
|
||
if (tags) {
|
||
const ILLEGAL_TAGS = [
|
||
'script', 'head', 'body', 'html', 'canvas', 'base', 'meta', 'link', 'iframe',
|
||
];
|
||
const LEGAL_AUTOCLOSE_TAGS = [
|
||
// void elements (no-close tags)
|
||
'br', 'area', 'embed', 'hr', 'img', 'source', 'track', 'input', 'wbr', 'col',
|
||
// autoclose tags
|
||
'p', 'li', 'dt', 'dd', 'option', 'tr', 'th', 'td', 'thead', 'tbody', 'tfoot', 'colgroup',
|
||
// PS custom element
|
||
'psicon', 'youtube',
|
||
];
|
||
const stack = [];
|
||
for (const tag of tags) {
|
||
const isClosingTag = tag.charAt(1) === '/';
|
||
const contentEndLoc = tag.endsWith('/') ? -1 : undefined;
|
||
const tagContent = tag.slice(isClosingTag ? 2 : 1, contentEndLoc).replace(/\s+/, ' ').trim();
|
||
const tagNameEndIndex = tagContent.indexOf(' ');
|
||
const tagName = tagContent.slice(0, tagNameEndIndex >= 0 ? tagNameEndIndex : undefined).toLowerCase();
|
||
if (tagName === '!--') continue;
|
||
if (isClosingTag) {
|
||
if (LEGAL_AUTOCLOSE_TAGS.includes(tagName)) continue;
|
||
if (!stack.length) {
|
||
throw new Chat.ErrorMessage(`Extraneous </${tagName}> without an opening tag.`);
|
||
}
|
||
const expectedTagName = stack.pop();
|
||
if (tagName !== expectedTagName) {
|
||
throw new Chat.ErrorMessage(`Extraneous </${tagName}> where </${expectedTagName}> was expected.`);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ILLEGAL_TAGS.includes(tagName) || !/^[a-z]+[0-9]?$/.test(tagName)) {
|
||
throw new Chat.ErrorMessage(`Illegal tag <${tagName}> can't be used here.`);
|
||
}
|
||
if (!LEGAL_AUTOCLOSE_TAGS.includes(tagName)) {
|
||
stack.push(tagName);
|
||
}
|
||
|
||
if (tagName === 'img') {
|
||
if (!this.room || (this.room.settings.isPersonal && !this.user.can('lock'))) {
|
||
throw new Chat.ErrorMessage(
|
||
`This tag is not allowed: <${tagContent}>. Images are not allowed outside of chatrooms.`
|
||
);
|
||
}
|
||
if (!/width ?= ?(?:[0-9]+|"[0-9]+")/i.test(tagContent) || !/height ?= ?(?:[0-9]+|"[0-9]+")/i.test(tagContent)) {
|
||
// Width and height are required because most browsers insert the
|
||
// <img> element before width and height are known, and when the
|
||
// image is loaded, this changes the height of the chat area, which
|
||
// messes up autoscrolling.
|
||
this.errorReply(`This image is missing a width/height attribute: <${tagContent}>`);
|
||
throw new Chat.ErrorMessage(`Images without predefined width/height cause problems with scrolling because loading them changes their height.`);
|
||
}
|
||
const srcMatch = / src ?= ?"?([^ "]+)(?: ?")?/i.exec(tagContent);
|
||
if (srcMatch) {
|
||
this.checkEmbedURI(srcMatch[1]);
|
||
} else {
|
||
this.errorReply(`This image has a broken src attribute: <${tagContent}>`);
|
||
throw new Chat.ErrorMessage(`The src attribute must exist and have no spaces in the URL`);
|
||
}
|
||
}
|
||
if (tagName === 'button') {
|
||
if ((!this.room || this.room.settings.isPersonal || this.room.settings.isPrivate === true) && !this.user.can('lock')) {
|
||
const buttonName = / name ?= ?"([^"]*)"/i.exec(tagContent)?.[1];
|
||
const buttonValue = / value ?= ?"([^"]*)"/i.exec(tagContent)?.[1];
|
||
const msgCommandRegex = /^\/(?:msg|pm|w|whisper|botmsg) /;
|
||
const botmsgCommandRegex = /^\/msgroom (?:[a-z0-9-]+), ?\/botmsg /;
|
||
if (buttonName === 'send' && buttonValue && msgCommandRegex.test(buttonValue)) {
|
||
const [pmTarget] = buttonValue.replace(msgCommandRegex, '').split(',');
|
||
const auth = this.room ? this.room.auth : Users.globalAuth;
|
||
if (auth.get(toID(pmTarget)) !== '*' && toID(pmTarget) !== this.user.id) {
|
||
this.errorReply(`This button is not allowed: <${tagContent}>`);
|
||
throw new Chat.ErrorMessage(`Your scripted button can't send PMs to ${pmTarget}, because that user is not a Room Bot.`);
|
||
}
|
||
} else if (buttonName === 'send' && buttonValue && botmsgCommandRegex.test(buttonValue)) {
|
||
// no need to validate the bot being an actual bot; `/botmsg` will do it for us and is not abusable
|
||
} else if (buttonName) {
|
||
this.errorReply(`This button is not allowed: <${tagContent}>`);
|
||
this.errorReply(`You do not have permission to use most buttons. Here are the two types you're allowed to use:`);
|
||
this.errorReply(`1. Linking to a room: <a href="/roomid"><button>go to a place</button></a>`);
|
||
throw new Chat.ErrorMessage(`2. Sending a message to a Bot: <button name="send" value="/msgroom BOT_ROOMID, /botmsg BOT_USERNAME, MESSAGE">send the thing</button>`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (stack.length) {
|
||
throw new Chat.ErrorMessage(`Missing </${stack.pop()}>.`);
|
||
}
|
||
}
|
||
|
||
return htmlContent;
|
||
}
|
||
targetUserOrSelf(target: string, exactName: boolean) {
|
||
if (!target) {
|
||
this.targetUsername = this.user.name;
|
||
this.inputUsername = this.user.name;
|
||
return this.user;
|
||
}
|
||
this.splitTarget(target, exactName);
|
||
return this.targetUser;
|
||
}
|
||
|
||
/**
|
||
* Given a message in the form "USERNAME" or "USERNAME, MORE", splits
|
||
* it apart:
|
||
*
|
||
* - `this.targetUser` will be the User corresponding to USERNAME
|
||
* (or null, if not found)
|
||
*
|
||
* - `this.inputUsername` will be the text of USERNAME, unmodified
|
||
*
|
||
* - `this.targetUsername` will be the username, if found, or
|
||
* this.inputUsername otherwise
|
||
*
|
||
* - and the text of MORE will be returned (empty string, if the
|
||
* message has no comma)
|
||
*
|
||
*/
|
||
splitTarget(target: string, exactName = false) {
|
||
const [name, rest] = this.splitOne(target);
|
||
|
||
this.targetUser = Users.get(name, exactName);
|
||
this.inputUsername = name.trim();
|
||
this.targetUsername = this.targetUser ? this.targetUser.name : this.inputUsername;
|
||
return rest;
|
||
}
|
||
|
||
requireRoom(id?: RoomID) {
|
||
if (!this.room) {
|
||
throw new Chat.ErrorMessage(`/${this.cmd} - must be used in a chat room, not a ${this.pmTarget ? "PM" : "console"}`);
|
||
}
|
||
if (id && this.room.roomid !== id) {
|
||
const targetRoom = Rooms.get(id);
|
||
if (!targetRoom) {
|
||
throw new Chat.ErrorMessage(`This command can only be used in the room '${id}', but that room does not exist.`);
|
||
}
|
||
throw new Chat.ErrorMessage(`This command can only be used in the ${targetRoom.title} room.`);
|
||
}
|
||
return this.room;
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/type-annotation-spacing
|
||
requireGame<T extends RoomGame>(constructor: new (...args: any[]) => T) {
|
||
const room = this.requireRoom();
|
||
if (!room.game) {
|
||
throw new Chat.ErrorMessage(`This command requires a game of ${constructor.name} (this room has no game).`);
|
||
}
|
||
const game = room.getGame(constructor);
|
||
// must be a different game
|
||
if (!game) {
|
||
throw new Chat.ErrorMessage(`This command requires a game of ${constructor.name} (this game is ${room.game.title}).`);
|
||
}
|
||
return game;
|
||
}
|
||
requireMinorActivity<T extends MinorActivity>(constructor: new (...args: any[]) => T) {
|
||
const room = this.requireRoom();
|
||
if (!room.minorActivity) {
|
||
throw new Chat.ErrorMessage(`This command requires a ${constructor.name} (this room has no minor activity).`);
|
||
}
|
||
const game = room.getMinorActivity(constructor);
|
||
// must be a different game
|
||
if (!game) {
|
||
throw new Chat.ErrorMessage(`This command requires a ${constructor.name} (this minor activity is a(n) ${room.minorActivity.name}).`);
|
||
}
|
||
return game;
|
||
}
|
||
commandDoesNotExist(): never {
|
||
if (this.cmdToken === '!') {
|
||
throw new Chat.ErrorMessage(`The command "${this.cmdToken}${this.fullCmd}" does not exist.`);
|
||
}
|
||
throw new Chat.ErrorMessage(
|
||
`The command "${this.cmdToken}${this.fullCmd}" does not exist. To send a message starting with "${this.cmdToken}${this.fullCmd}", type "${this.cmdToken}${this.cmdToken}${this.fullCmd}".`
|
||
);
|
||
}
|
||
refreshPage(pageid: string) {
|
||
if (this.connection.openPages?.has(pageid)) {
|
||
this.parse(`/join view-${pageid}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const Chat = new class {
|
||
constructor() {
|
||
void this.loadTranslations().then(() => {
|
||
Chat.translationsLoaded = true;
|
||
});
|
||
}
|
||
translationsLoaded = false;
|
||
/**
|
||
* As per the node.js documentation at https://nodejs.org/api/timers.html#timers_settimeout_callback_delay_args,
|
||
* timers with durations that are too long for a 32-bit signed integer will be invoked after 1 millisecond,
|
||
* which tends to cause unexpected behavior.
|
||
*/
|
||
readonly MAX_TIMEOUT_DURATION = 2147483647;
|
||
|
||
readonly multiLinePattern = new PatternTester();
|
||
|
||
/*********************************************************
|
||
* Load command files
|
||
*********************************************************/
|
||
baseCommands!: AnnotatedChatCommands;
|
||
commands!: AnnotatedChatCommands;
|
||
basePages!: PageTable;
|
||
pages!: PageTable;
|
||
readonly destroyHandlers: (() => void)[] = [];
|
||
readonly crqHandlers: {[k: string]: CRQHandler} = {};
|
||
readonly renameHandlers: Rooms.RenameHandler[] = [];
|
||
/** The key is the name of the plugin. */
|
||
readonly plugins: {[k: string]: ChatPlugin} = {};
|
||
/** Will be empty except during hotpatch */
|
||
oldPlugins: {[k: string]: ChatPlugin} = {};
|
||
roomSettings: SettingsHandler[] = [];
|
||
|
||
/*********************************************************
|
||
* Load chat filters
|
||
*********************************************************/
|
||
readonly filters: ChatFilter[] = [];
|
||
filter(message: string, context: CommandContext) {
|
||
// Chat filters can choose to:
|
||
// 1. return false OR null - to not send a user's message
|
||
// 2. return an altered string - to alter a user's message
|
||
// 3. return undefined to send the original message through
|
||
const originalMessage = message;
|
||
for (const curFilter of Chat.filters) {
|
||
const output = curFilter.call(
|
||
context,
|
||
message,
|
||
context.user,
|
||
context.room,
|
||
context.connection,
|
||
context.pmTarget,
|
||
originalMessage
|
||
);
|
||
if (output === false) return null;
|
||
if (!output && output !== undefined) return output;
|
||
if (output !== undefined) message = output;
|
||
}
|
||
|
||
return message;
|
||
}
|
||
|
||
readonly namefilters: NameFilter[] = [];
|
||
namefilter(name: string, user: User) {
|
||
if (!Config.disablebasicnamefilter) {
|
||
// whitelist
|
||
// \u00A1-\u00BF\u00D7\u00F7 Latin punctuation/symbols
|
||
// \u02B9-\u0362 basic combining accents
|
||
// \u2012-\u2027\u2030-\u205E Latin punctuation/symbols extended
|
||
// \u2050-\u205F fractions extended
|
||
// \u2190-\u23FA\u2500-\u2BD1 misc symbols
|
||
// \u2E80-\u32FF CJK symbols
|
||
// \u3400-\u9FFF CJK
|
||
// \uF900-\uFAFF\uFE00-\uFE6F CJK extended
|
||
// eslint-disable-next-line no-misleading-character-class, max-len
|
||
name = name.replace(/[^a-zA-Z0-9 /\\.~()<>^*%&=+$#_'?!"\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2190-\u23FA\u2500-\u2BD1\u2E80-\u32FF\u3400-\u9FFF\uF900-\uFAFF\uFE00-\uFE6F-]+/g, '');
|
||
|
||
// blacklist
|
||
// \u00a1 upside-down exclamation mark (i)
|
||
// \u2580-\u2590 black bars
|
||
// \u25A0\u25Ac\u25AE\u25B0 black bars
|
||
// \u534d\u5350 swastika
|
||
// \u2a0d crossed integral (f)
|
||
name = name.replace(/[\u00a1\u2580-\u2590\u25A0\u25Ac\u25AE\u25B0\u2a0d\u534d\u5350]/g, '');
|
||
|
||
// e-mail address
|
||
if (name.includes('@') && name.includes('.')) return '';
|
||
|
||
// url
|
||
if (/[a-z0-9]\.(com|net|org|us|uk|co|gg|tk|ml|gq|ga|xxx|download|stream)\b/i.test(name)) name = name.replace(/\./g, '');
|
||
|
||
// Limit the amount of symbols allowed in usernames to 4 maximum, and
|
||
// disallow (R) and (C) from being used in the middle of names.
|
||
// eslint-disable-next-line max-len
|
||
const nameSymbols = name.replace(/[^\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2090-\u23FA\u2500-\u2BD1]+/g, '');
|
||
// \u00ae\u00a9 (R) (C)
|
||
// eslint-disable-next-line no-misleading-character-class, max-len
|
||
if (nameSymbols.length > 4 || /[^a-z0-9][a-z0-9][^a-z0-9]/.test(name.toLowerCase() + ' ') || /[\u00ae\u00a9].*[a-zA-Z0-9]/.test(name)) name = name.replace(/[\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2190-\u23FA\u2500-\u2BD1\u2E80-\u32FF\u3400-\u9FFF\uF900-\uFAFF\uFE00-\uFE6F]+/g, '').replace(/[^A-Za-z0-9]{2,}/g, ' ').trim();
|
||
}
|
||
name = name.replace(/^[^A-Za-z0-9]+/, ""); // remove symbols from start
|
||
name = name.replace(/@/g, ""); // Remove @ as this is used to indicate status messages
|
||
|
||
// cut name length down to 18 chars
|
||
if (/[A-Za-z0-9]/.test(name.slice(18))) {
|
||
name = name.replace(/[^A-Za-z0-9]+/g, "");
|
||
} else {
|
||
name = name.slice(0, 18);
|
||
}
|
||
|
||
name = Dex.getName(name);
|
||
for (const curFilter of Chat.namefilters) {
|
||
name = curFilter(name, user);
|
||
if (!name) return '';
|
||
}
|
||
return name;
|
||
}
|
||
|
||
readonly hostfilters: HostFilter[] = [];
|
||
hostfilter(host: string, user: User, connection: Connection, hostType: string) {
|
||
for (const curFilter of Chat.hostfilters) {
|
||
curFilter(host, user, connection, hostType);
|
||
}
|
||
}
|
||
|
||
readonly loginfilters: LoginFilter[] = [];
|
||
loginfilter(user: User, oldUser: User | null, usertype: string) {
|
||
for (const curFilter of Chat.loginfilters) {
|
||
curFilter(user, oldUser, usertype);
|
||
}
|
||
}
|
||
|
||
readonly punishmentfilters: PunishmentFilter[] = [];
|
||
punishmentfilter(user: User | ID, punishment: Punishment) {
|
||
for (const curFilter of Chat.punishmentfilters) {
|
||
curFilter(user, punishment);
|
||
}
|
||
}
|
||
|
||
readonly nicknamefilters: NicknameFilter[] = [];
|
||
nicknamefilter(nickname: string, user: User) {
|
||
for (const curFilter of Chat.nicknamefilters) {
|
||
const filtered = curFilter(nickname, user);
|
||
if (filtered === false) return false;
|
||
if (!filtered) return '';
|
||
}
|
||
return nickname;
|
||
}
|
||
|
||
readonly statusfilters: StatusFilter[] = [];
|
||
statusfilter(status: string, user: User) {
|
||
status = status.replace(/\|/g, '');
|
||
for (const curFilter of Chat.statusfilters) {
|
||
status = curFilter(status, user);
|
||
if (!status) return '';
|
||
}
|
||
return status;
|
||
}
|
||
/*********************************************************
|
||
* Translations
|
||
*********************************************************/
|
||
/** language id -> language name */
|
||
readonly languages = new Map<ID, string>();
|
||
/** language id -> (english string -> translated string) */
|
||
readonly translations = new Map<ID, Map<string, [string, string[], string[]]>>();
|
||
|
||
async loadTranslations() {
|
||
const directories = await FS(TRANSLATION_DIRECTORY).readdir();
|
||
|
||
// ensure that english is the first entry when we iterate over Chat.languages
|
||
Chat.languages.set('english' as ID, 'English');
|
||
for (const dirname of directories) {
|
||
// translation dirs shouldn't have caps, but things like sourceMaps and the README will
|
||
if (/[^a-z0-9]/.test(dirname)) continue;
|
||
const dir = FS(`${TRANSLATION_DIRECTORY}/${dirname}`);
|
||
|
||
// For some reason, toID() isn't available as a global when this executes.
|
||
const languageID = Dex.toID(dirname);
|
||
const files = await dir.readdir();
|
||
for (const filename of files) {
|
||
if (!filename.endsWith('.js')) continue;
|
||
|
||
const content: Translations = require(`${TRANSLATION_DIRECTORY}/${dirname}/${filename}`).translations;
|
||
|
||
if (!Chat.translations.has(languageID)) {
|
||
Chat.translations.set(languageID, new Map());
|
||
}
|
||
const translationsSoFar = Chat.translations.get(languageID)!;
|
||
|
||
if (content.name && !Chat.languages.has(languageID)) {
|
||
Chat.languages.set(languageID, content.name);
|
||
}
|
||
|
||
if (content.strings) {
|
||
for (const key in content.strings) {
|
||
const keyLabels: string[] = [];
|
||
const valLabels: string[] = [];
|
||
const newKey = key.replace(/\${.+?}/g, str => {
|
||
keyLabels.push(str);
|
||
return '${}';
|
||
}).replace(/\[TN: ?.+?\]/g, '');
|
||
const val = content.strings[key].replace(/\${.+?}/g, (str: string) => {
|
||
valLabels.push(str);
|
||
return '${}';
|
||
}).replace(/\[TN: ?.+?\]/g, '');
|
||
translationsSoFar.set(newKey, [val, keyLabels, valLabels]);
|
||
}
|
||
}
|
||
}
|
||
if (!Chat.languages.has(languageID)) {
|
||
// Fallback in case no translation files provide the language's name
|
||
Chat.languages.set(languageID, "Unknown Language");
|
||
}
|
||
}
|
||
}
|
||
tr(language: ID | null): (fStrings: TemplateStringsArray | string, ...fKeys: any) => string;
|
||
tr(language: ID | null, strings: TemplateStringsArray | string, ...keys: any[]): string;
|
||
tr(language: ID | null, strings: TemplateStringsArray | string = '', ...keys: any[]) {
|
||
if (!language) language = 'english' as ID;
|
||
// If strings is an array (normally the case), combine before translating.
|
||
const trString = typeof strings === 'string' ? strings : strings.join('${}');
|
||
|
||
if (!Chat.translations.has(language)) {
|
||
if (!Chat.translationsLoaded) return trString;
|
||
throw new Error(`Trying to translate to a nonexistent language: ${language}`);
|
||
}
|
||
if (!strings.length) {
|
||
return ((fStrings: TemplateStringsArray | string, ...fKeys: any) => Chat.tr(language, fStrings, ...fKeys));
|
||
}
|
||
|
||
const entry = Chat.translations.get(language)!.get(trString);
|
||
let [translated, keyLabels, valLabels] = entry || ["", [], []];
|
||
if (!translated) translated = trString;
|
||
|
||
// Replace the gaps in the species string
|
||
if (keys.length) {
|
||
let reconstructed = '';
|
||
|
||
const left: (string | null)[] = keyLabels.slice();
|
||
for (const [i, str] of translated.split('${}').entries()) {
|
||
reconstructed += str;
|
||
if (keys[i]) {
|
||
let index = left.indexOf(valLabels[i]);
|
||
if (index < 0) {
|
||
index = left.findIndex(val => !!val);
|
||
}
|
||
if (index < 0) index = i;
|
||
reconstructed += keys[index];
|
||
left[index] = null;
|
||
}
|
||
}
|
||
|
||
translated = reconstructed;
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
readonly MessageContext = MessageContext;
|
||
readonly CommandContext = CommandContext;
|
||
readonly PageContext = PageContext;
|
||
readonly ErrorMessage = ErrorMessage;
|
||
readonly Interruption = Interruption;
|
||
/**
|
||
* Command parser
|
||
*
|
||
* Usage:
|
||
* Chat.parse(message, room, user, connection)
|
||
*
|
||
* Parses the message. If it's a command, the command is executed, if
|
||
* not, it's displayed directly in the room.
|
||
*
|
||
* Examples:
|
||
* Chat.parse("/join lobby", room, user, connection)
|
||
* will make the user join the lobby.
|
||
*
|
||
* Chat.parse("Hi, guys!", room, user, connection)
|
||
* will return "Hi, guys!" if the user isn't muted, or
|
||
* if he's muted, will warn him that he's muted.
|
||
*
|
||
* The return value is the return value of the command handler, if any,
|
||
* or the message, if there wasn't a command. This value could be a success
|
||
* or failure (few commands report these) or a Promise for when the command
|
||
* is done executing, if it's not currently done.
|
||
*
|
||
* @param message - the message the user is trying to say
|
||
* @param room - the room the user is trying to say it in
|
||
* @param user - the user that sent the message
|
||
* @param connection - the connection the user sent the message from
|
||
*/
|
||
parse(message: string, room: Room | null | undefined, user: User, connection: Connection) {
|
||
Chat.loadPlugins();
|
||
|
||
const initialRoomlogLength = room?.log.getLineCount();
|
||
const context = new CommandContext({message, room, user, connection});
|
||
const start = Date.now();
|
||
const result = context.parse();
|
||
if (typeof result?.then === 'function') {
|
||
void result.then(() => {
|
||
this.logSlowMessage(start, context);
|
||
});
|
||
} else {
|
||
this.logSlowMessage(start, context);
|
||
}
|
||
if (room && room.log.getLineCount() !== initialRoomlogLength) {
|
||
room.messagesSent++;
|
||
for (const [handler, numMessages] of room.nthMessageHandlers) {
|
||
if (room.messagesSent % numMessages === 0) handler(room, message);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
logSlowMessage(start: number, context: CommandContext) {
|
||
const timeUsed = Date.now() - start;
|
||
if (timeUsed < 1000) return;
|
||
if (context.cmd === 'search' || context.cmd === 'savereplay') return;
|
||
|
||
const logMessage = (
|
||
`[slow command] ${timeUsed}ms - ${context.user.name} (${context.connection.ip}): ` +
|
||
`<${context.room ? context.room.roomid : context.pmTarget ? `PM:${context.pmTarget?.name}` : 'CMD'}> ` +
|
||
`${context.message.replace(/\n/ig, ' ')}`
|
||
);
|
||
|
||
Monitor.slow(logMessage);
|
||
}
|
||
sendPM(message: string, user: User, pmTarget: User, onlyRecipient: User | null = null) {
|
||
const buf = `|pm|${user.getIdentity()}|${pmTarget.getIdentity()}|${message}`;
|
||
if (onlyRecipient) return onlyRecipient.send(buf);
|
||
user.send(buf);
|
||
if (pmTarget !== user) pmTarget.send(buf);
|
||
pmTarget.lastPM = user.id;
|
||
user.lastPM = pmTarget.id;
|
||
}
|
||
|
||
packageData: AnyObject = {};
|
||
|
||
loadPlugin(file: string) {
|
||
let plugin;
|
||
if (file.endsWith('.ts')) {
|
||
plugin = require(`./${file.slice(0, -3)}`);
|
||
} else if (file.endsWith('.js')) {
|
||
// Switch to server/ because we'll be in .server-dist/ after this file is compiled
|
||
plugin = require(`../server/${file}`);
|
||
} else {
|
||
return;
|
||
}
|
||
this.loadPluginData(plugin, file.split('/').pop()?.slice(0, -3) || file);
|
||
}
|
||
annotateCommands(commandTable: AnyObject, namespace = ''): AnnotatedChatCommands {
|
||
for (const cmd in commandTable) {
|
||
const entry = commandTable[cmd];
|
||
if (typeof entry === 'object') {
|
||
this.annotateCommands(entry, `${namespace}${cmd} `);
|
||
}
|
||
if (typeof entry === 'string') {
|
||
const base = commandTable[entry];
|
||
if (!base) continue;
|
||
if (!base.aliases) base.aliases = [];
|
||
if (!base.aliases.includes(cmd)) base.aliases.push(cmd);
|
||
continue;
|
||
}
|
||
if (typeof entry !== 'function') continue;
|
||
|
||
const handlerCode = entry.toString();
|
||
entry.requiresRoom = /requireRoom\((?:'|"|`)(.*?)(?:'|"|`)/.exec(handlerCode)?.[1] as RoomID || /this\.requireRoom\(/.test(handlerCode);
|
||
entry.hasRoomPermissions = /\bthis\.(checkCan|can)\([^,)\n]*, [^,)\n]*,/.test(handlerCode);
|
||
entry.broadcastable = cmd.endsWith('help') || /\bthis\.(?:(check|can|run)Broadcast)\(/.test(handlerCode);
|
||
entry.isPrivate = /\bthis\.(?:privately(Check)?Can|commandDoesNotExist)\(/.test(handlerCode);
|
||
if (!entry.aliases) entry.aliases = [];
|
||
|
||
// assign properties from the base command if the current command uses CommandContext.run.
|
||
const runsCommand = /this.run\((?:'|"|`)(.*?)(?:'|"|`)\)/.exec(handlerCode);
|
||
if (runsCommand) {
|
||
const [, baseCommand] = runsCommand;
|
||
const baseEntry = commandTable[baseCommand];
|
||
if (baseEntry) {
|
||
if (baseEntry.requiresRoom) entry.requiresRoom = baseEntry.requiresRoom;
|
||
if (baseEntry.hasRoomPermissions) entry.hasRoomPermissions = baseEntry.hasRoomPermissions;
|
||
if (baseEntry.broadcastable) entry.broadcastable = baseEntry.broadcastable;
|
||
if (baseEntry.isPrivate) entry.isPrivate = baseEntry.isPrivate;
|
||
}
|
||
}
|
||
// This is usually the same as `entry.name`, but some weirdness like
|
||
// `commands.a = b` could screw it up. This should make it consistent.
|
||
entry.cmd = cmd;
|
||
entry.fullCmd = `${namespace}${cmd}`;
|
||
}
|
||
return commandTable;
|
||
}
|
||
loadPluginData(plugin: AnyObject, name: string) {
|
||
if (plugin.commands) {
|
||
Object.assign(Chat.commands, this.annotateCommands(plugin.commands));
|
||
}
|
||
if (plugin.pages) Object.assign(Chat.pages, plugin.pages);
|
||
|
||
if (plugin.destroy) Chat.destroyHandlers.push(plugin.destroy);
|
||
if (plugin.crqHandlers) {
|
||
Object.assign(Chat.crqHandlers, plugin.crqHandlers);
|
||
}
|
||
if (plugin.roomSettings) {
|
||
if (!Array.isArray(plugin.roomSettings)) plugin.roomSettings = [plugin.roomSettings];
|
||
Chat.roomSettings = Chat.roomSettings.concat(plugin.roomSettings);
|
||
}
|
||
if (plugin.chatfilter) Chat.filters.push(plugin.chatfilter);
|
||
if (plugin.namefilter) Chat.namefilters.push(plugin.namefilter);
|
||
if (plugin.hostfilter) Chat.hostfilters.push(plugin.hostfilter);
|
||
if (plugin.loginfilter) Chat.loginfilters.push(plugin.loginfilter);
|
||
if (plugin.punishmentfilter) Chat.punishmentfilters.push(plugin.punishmentfilter);
|
||
if (plugin.nicknamefilter) Chat.nicknamefilters.push(plugin.nicknamefilter);
|
||
if (plugin.statusfilter) Chat.statusfilters.push(plugin.statusfilter);
|
||
if (plugin.onRenameRoom) Chat.renameHandlers.push(plugin.onRenameRoom);
|
||
Chat.plugins[name] = plugin;
|
||
}
|
||
loadPlugins(oldPlugins?: {[k: string]: ChatPlugin}) {
|
||
if (Chat.commands) return;
|
||
if (oldPlugins) Chat.oldPlugins = oldPlugins;
|
||
|
||
void FS('package.json').readIfExists().then(data => {
|
||
if (data) Chat.packageData = JSON.parse(data);
|
||
});
|
||
|
||
// Install plug-in commands and chat filters
|
||
|
||
// All resulting filenames will be relative to basePath
|
||
const getFiles = (basePath: string, path: string): string[] => {
|
||
const filesInThisDir = FS(`${basePath}/${path}`).readdirSync();
|
||
let allFiles: string[] = [];
|
||
for (const file of filesInThisDir) {
|
||
const fileWithPath = path + (path ? '/' : '') + file;
|
||
if (FS(`${basePath}/${fileWithPath}`).isDirectorySync()) {
|
||
if (file.startsWith('.')) continue;
|
||
allFiles = allFiles.concat(getFiles(basePath, fileWithPath));
|
||
} else {
|
||
allFiles.push(fileWithPath);
|
||
}
|
||
}
|
||
return allFiles;
|
||
};
|
||
|
||
Chat.commands = Object.create(null);
|
||
Chat.pages = Object.create(null);
|
||
const coreFiles = FS('server/chat-commands').readdirSync();
|
||
for (const file of coreFiles) {
|
||
this.loadPlugin(`chat-commands/${file}`);
|
||
}
|
||
Chat.baseCommands = Chat.commands;
|
||
Chat.basePages = Chat.pages;
|
||
Chat.commands = Object.assign(Object.create(null), Chat.baseCommands);
|
||
Chat.pages = Object.assign(Object.create(null), Chat.basePages);
|
||
|
||
// Load filters from Config
|
||
this.loadPluginData(Config, 'config');
|
||
this.loadPluginData(Tournaments, 'tournaments');
|
||
|
||
let files = FS('server/chat-plugins').readdirSync();
|
||
try {
|
||
if (FS('server/chat-plugins/private').isDirectorySync()) {
|
||
files = files.concat(getFiles('server/chat-plugins', 'private'));
|
||
}
|
||
} catch (err) {
|
||
if (err.code !== 'ENOENT') throw err;
|
||
}
|
||
|
||
for (const file of files) {
|
||
this.loadPlugin(`chat-plugins/${file}`);
|
||
}
|
||
Chat.oldPlugins = {};
|
||
// lower priority should run later
|
||
Utils.sortBy(Chat.filters, filter => -(filter.priority || 0));
|
||
}
|
||
destroy() {
|
||
for (const handler of Chat.destroyHandlers) {
|
||
handler();
|
||
}
|
||
}
|
||
|
||
handleRoomRename(oldID: RoomID, newID: RoomID, room: Room) {
|
||
for (const handler of Chat.renameHandlers) {
|
||
handler(oldID, newID, room);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Takes a chat message and returns data about any command it's
|
||
* trying to use.
|
||
*
|
||
* Returning `null` means the chat message isn't trying to use
|
||
* a command, and returning `{handler: null}` means it's trying
|
||
* to use a command that doesn't exist.
|
||
*/
|
||
parseCommand(message: string, recursing = false): {
|
||
cmd: string, fullCmd: string, cmdToken: string, target: string, handler: AnnotatedChatHandler | null,
|
||
} | null {
|
||
if (!message.trim()) return null;
|
||
|
||
// hardcoded commands
|
||
if (message.startsWith(`>> `)) {
|
||
message = `/eval ${message.slice(3)}`;
|
||
} else if (message.startsWith(`>>> `)) {
|
||
message = `/evalbattle ${message.slice(4)}`;
|
||
} else if (message.startsWith(`/me`) && /[^A-Za-z0-9 ]/.test(message.charAt(3))) {
|
||
message = `/mee ${message.slice(3)}`;
|
||
} else if (message.startsWith(`/ME`) && /[^A-Za-z0-9 ]/.test(message.charAt(3))) {
|
||
message = `/MEE ${message.slice(3)}`;
|
||
}
|
||
|
||
const cmdToken = message.charAt(0);
|
||
if (!VALID_COMMAND_TOKENS.includes(cmdToken)) return null;
|
||
if (cmdToken === message.charAt(1)) return null;
|
||
if (cmdToken === BROADCAST_TOKEN && /[^A-Za-z0-9]/.test(message.charAt(1))) return null;
|
||
|
||
let [cmd, target] = Utils.splitFirst(message.slice(1), ' ');
|
||
cmd = cmd.toLowerCase();
|
||
|
||
if (cmd.endsWith(',')) cmd = cmd.slice(0, -1);
|
||
|
||
let curCommands: AnnotatedChatCommands = Chat.commands;
|
||
let commandHandler;
|
||
let fullCmd = cmd;
|
||
|
||
do {
|
||
if (cmd in curCommands) {
|
||
commandHandler = curCommands[cmd];
|
||
} else {
|
||
commandHandler = undefined;
|
||
}
|
||
if (typeof commandHandler === 'string') {
|
||
// in case someone messed up, don't loop
|
||
commandHandler = curCommands[commandHandler];
|
||
} else if (Array.isArray(commandHandler) && !recursing) {
|
||
return this.parseCommand(cmdToken + 'help ' + fullCmd.slice(0, -4), true);
|
||
}
|
||
if (commandHandler && typeof commandHandler === 'object') {
|
||
[cmd, target] = Utils.splitFirst(target, ' ');
|
||
cmd = cmd.toLowerCase();
|
||
|
||
fullCmd += ' ' + cmd;
|
||
curCommands = commandHandler as AnnotatedChatCommands;
|
||
}
|
||
} while (commandHandler && typeof commandHandler === 'object');
|
||
|
||
if (!commandHandler && curCommands.default) {
|
||
commandHandler = curCommands.default;
|
||
if (typeof commandHandler === 'string') {
|
||
commandHandler = curCommands[commandHandler];
|
||
}
|
||
}
|
||
|
||
if (!commandHandler && !recursing) {
|
||
for (const g in Config.groups) {
|
||
const groupid = Config.groups[g].id;
|
||
if (fullCmd === groupid) {
|
||
return this.parseCommand(`/promote ${target}, ${g}`, true);
|
||
} else if (fullCmd === 'global' + groupid) {
|
||
return this.parseCommand(`/globalpromote ${target}, ${g}`, true);
|
||
} else if (fullCmd === 'de' + groupid || fullCmd === 'un' + groupid ||
|
||
fullCmd === 'globalde' + groupid || fullCmd === 'deglobal' + groupid) {
|
||
return this.parseCommand(`/demote ${target}`, true);
|
||
} else if (fullCmd === 'room' + groupid) {
|
||
return this.parseCommand(`/roompromote ${target}, ${g}`, true);
|
||
} else if (fullCmd === 'forceroom' + groupid) {
|
||
return this.parseCommand(`/forceroompromote ${target}, ${g}`, true);
|
||
} else if (fullCmd === 'roomde' + groupid || fullCmd === 'deroom' + groupid || fullCmd === 'roomun' + groupid) {
|
||
return this.parseCommand(`/roomdemote ${target}`, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
cmd: cmd,
|
||
cmdToken: cmdToken,
|
||
target: target,
|
||
fullCmd: fullCmd,
|
||
handler: commandHandler as AnnotatedChatHandler | null,
|
||
};
|
||
}
|
||
allCommands(table: ChatCommands = Chat.commands) {
|
||
const results: AnnotatedChatHandler[] = [];
|
||
for (const cmd in table) {
|
||
const handler = table[cmd];
|
||
if (Array.isArray(handler) || !handler || ['string', 'boolean'].includes(typeof handler)) {
|
||
continue;
|
||
}
|
||
if (typeof handler === 'object') {
|
||
results.push(...this.allCommands(handler));
|
||
continue;
|
||
}
|
||
results.push(handler as AnnotatedChatHandler);
|
||
}
|
||
if (table !== Chat.commands) return results;
|
||
return results.filter((handler, i) => results.indexOf(handler) === i);
|
||
}
|
||
|
||
/**
|
||
* Strips HTML from a string.
|
||
*/
|
||
stripHTML(htmlContent: string) {
|
||
if (!htmlContent) return '';
|
||
return htmlContent.replace(/<[^>]*>/g, '');
|
||
}
|
||
/**
|
||
* Validates input regex and ensures it won't crash.
|
||
*/
|
||
validateRegex(word: string) {
|
||
word = word.trim();
|
||
if ((word.endsWith('|') && !word.endsWith('\\|')) || word.startsWith('|')) {
|
||
throw new Chat.ErrorMessage(`Your regex was rejected because it included an unterminated |.`);
|
||
}
|
||
try {
|
||
// eslint-disable-next-line no-new
|
||
new RegExp(word);
|
||
} catch (e) {
|
||
throw new Chat.ErrorMessage(
|
||
e.message.startsWith('Invalid regular expression: ') ?
|
||
e.message :
|
||
`Invalid regular expression: /${word}/: ${e.message}`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns singular (defaulting to '') if num is 1, or plural
|
||
* (defaulting to 's') otherwise. Helper function for pluralizing
|
||
* words.
|
||
*/
|
||
plural(num: any, pluralSuffix = 's', singular = '') {
|
||
if (num && typeof num.length === 'number') {
|
||
num = num.length;
|
||
} else if (num && typeof num.size === 'number') {
|
||
num = num.size;
|
||
} else {
|
||
num = Number(num);
|
||
}
|
||
return (num !== 1 ? pluralSuffix : singular);
|
||
}
|
||
|
||
/**
|
||
* Counts the thing passed.
|
||
*
|
||
* Chat.count(2, "days") === "2 days"
|
||
* Chat.count(1, "days") === "1 day"
|
||
* Chat.count(["foo"], "things are") === "1 thing is"
|
||
*
|
||
*/
|
||
count(num: any, pluralSuffix: string, singular = "") {
|
||
if (num && typeof num.length === 'number') {
|
||
num = num.length;
|
||
} else if (num && typeof num.size === 'number') {
|
||
num = num.size;
|
||
} else {
|
||
num = Number(num);
|
||
}
|
||
if (!singular) {
|
||
if (pluralSuffix.endsWith("s")) {
|
||
singular = pluralSuffix.slice(0, -1);
|
||
} else if (pluralSuffix.endsWith("s have")) {
|
||
singular = pluralSuffix.slice(0, -6) + " has";
|
||
} else if (pluralSuffix.endsWith("s were")) {
|
||
singular = pluralSuffix.slice(0, -6) + " was";
|
||
}
|
||
}
|
||
const space = singular.startsWith('<') ? '' : ' ';
|
||
return `${num}${space}${num > 1 ? pluralSuffix : singular}`;
|
||
}
|
||
|
||
/**
|
||
* Returns a timestamp in the form {yyyy}-{MM}-{dd} {hh}:{mm}:{ss}.
|
||
*
|
||
* options.human = true will reports hours human-readable
|
||
*/
|
||
toTimestamp(date: Date, options: {human?: boolean} = {}) {
|
||
const human = options.human;
|
||
let parts: any[] = [
|
||
date.getFullYear(), date.getMonth() + 1, date.getDate(),
|
||
date.getHours(), date.getMinutes(), date.getSeconds(),
|
||
];
|
||
if (human) {
|
||
parts.push(parts[3] >= 12 ? 'pm' : 'am');
|
||
parts[3] = parts[3] % 12 || 12;
|
||
}
|
||
parts = parts.map(val => val < 10 ? '0' + val : '' + val);
|
||
return parts.slice(0, 3).join("-") + " " + parts.slice(3, human ? 5 : 6).join(":") + (human ? "" + parts[6] : "");
|
||
}
|
||
|
||
/**
|
||
* Takes a number of milliseconds, and reports the duration in English: hours, minutes, etc.
|
||
*
|
||
* options.hhmmss = true will instead report the duration in 00:00:00 format
|
||
*
|
||
*/
|
||
toDurationString(val: number, options: {hhmmss?: boolean, precision?: number} = {}) {
|
||
// TODO: replace by Intl.DurationFormat or equivalent when it becomes available (ECMA-402)
|
||
// https://github.com/tc39/ecma402/issues/47
|
||
const date = new Date(+val);
|
||
if (isNaN(date.getTime())) return 'forever';
|
||
|
||
const parts = [
|
||
date.getUTCFullYear() - 1970, date.getUTCMonth(), date.getUTCDate() - 1,
|
||
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(),
|
||
];
|
||
const roundingBoundaries = [6, 15, 12, 30, 30];
|
||
const unitNames = ["second", "minute", "hour", "day", "month", "year"];
|
||
const positiveIndex = parts.findIndex(elem => elem > 0);
|
||
let precision = (options?.precision ? options.precision : 3);
|
||
if (options?.hhmmss) {
|
||
const str = parts.slice(positiveIndex).map(value => value < 10 ? "0" + value : "" + value).join(":");
|
||
return str.length === 2 ? "00:" + str : str;
|
||
}
|
||
|
||
// round least significant displayed unit
|
||
if (positiveIndex + precision < parts.length && precision > 0 && positiveIndex >= 0) {
|
||
if (parts[positiveIndex + precision] >= roundingBoundaries[positiveIndex + precision - 1]) {
|
||
parts[positiveIndex + precision - 1]++;
|
||
}
|
||
}
|
||
|
||
// don't display trailing 0's if the number is exact
|
||
let precisionIndex = 5;
|
||
while (precisionIndex > positiveIndex && !parts[precisionIndex]) {
|
||
precisionIndex--;
|
||
}
|
||
precision = Math.min(precision, precisionIndex - positiveIndex + 1);
|
||
|
||
return parts
|
||
.slice(positiveIndex)
|
||
.reverse()
|
||
.map((value, index) => `${value} ${unitNames[index]}${value !== 1 ? "s" : ""}`)
|
||
.reverse()
|
||
.slice(0, precision)
|
||
.join(" ")
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Takes an array and turns it into a sentence string by adding commas and the word "and"
|
||
*/
|
||
toListString(arr: string[]) {
|
||
if (!arr.length) return '';
|
||
if (arr.length === 1) return arr[0];
|
||
if (arr.length === 2) return `${arr[0]} and ${arr[1]}`;
|
||
return `${arr.slice(0, -1).join(", ")}, and ${arr.slice(-1)[0]}`;
|
||
}
|
||
|
||
/**
|
||
* Takes an array and turns it into a sentence string by adding commas and the word "or"
|
||
*/
|
||
toOrList(arr: string[]) {
|
||
if (!arr.length) return '';
|
||
if (arr.length === 1) return arr[0];
|
||
if (arr.length === 2) return `${arr[0]} or ${arr[1]}`;
|
||
return `${arr.slice(0, -1).join(", ")}, or ${arr.slice(-1)[0]}`;
|
||
}
|
||
|
||
/**
|
||
* Convert multiline HTML into a single line without losing whitespace (so
|
||
* <pre> blocks still render correctly). Linebreaks inside <> are replaced
|
||
* with ` `, and linebreaks outside <> are replaced with ` `.
|
||
*
|
||
* PS's protocol often requires sending a block of HTML in a single line,
|
||
* so this ensures any block of HTML ends up as a single line.
|
||
*/
|
||
collapseLineBreaksHTML(htmlContent: string) {
|
||
htmlContent = htmlContent.replace(/<[^>]*>/g, tag => tag.replace(/\n/g, ' '));
|
||
htmlContent = htmlContent.replace(/\n/g, ' ');
|
||
return htmlContent;
|
||
}
|
||
/**
|
||
* Takes a string of text and transforms it into a block of html using the details tag.
|
||
* If it has a newline, will make the 3 lines the preview, and fill the rest in.
|
||
* @param str string to block
|
||
*/
|
||
getReadmoreBlock(str: string, isCode?: boolean, cutoff = 3) {
|
||
const params = str.slice(+str.startsWith('\n')).split('\n');
|
||
const output: string[] = [];
|
||
for (const param of params) {
|
||
if (output.length < cutoff && param.length > 80 && cutoff > 2) cutoff--;
|
||
output.push(Utils[isCode ? 'escapeHTMLForceWrap' : 'escapeHTML'](param));
|
||
}
|
||
|
||
if (output.length > cutoff) {
|
||
return `<details class="readmore${isCode ? ` code" style="white-space: pre-wrap; display: table; tab-size: 3` : ``}"><summary>${
|
||
output.slice(0, cutoff).join('<br />')
|
||
}</summary>${
|
||
output.slice(cutoff).join('<br />')
|
||
}</details>`;
|
||
} else {
|
||
const tag = isCode ? `code` : `div`;
|
||
return `<${tag} style="white-space: pre-wrap; display: table; tab-size: 3">${
|
||
output.join('<br />')
|
||
}</${tag}>`;
|
||
}
|
||
}
|
||
|
||
getReadmoreCodeBlock(str: string, cutoff?: number) {
|
||
return Chat.getReadmoreBlock(str, true, cutoff);
|
||
}
|
||
|
||
getDataPokemonHTML(species: Species, gen = 8, tier = '') {
|
||
let buf = '<li class="result">';
|
||
buf += '<span class="col numcol">' + (tier || species.tier) + '</span> ';
|
||
buf += `<span class="col iconcol"><psicon pokemon="${species.id}"/></span> `;
|
||
buf += `<span class="col pokemonnamecol" style="white-space:nowrap"><a href="https://${Config.routes.dex}/pokemon/${species.id}" target="_blank">${species.name}</a></span> `;
|
||
buf += '<span class="col typecol">';
|
||
if (species.types) {
|
||
for (const type of species.types) {
|
||
buf += `<img src="https://${Config.routes.client}/sprites/types/${type}.png" alt="${type}" height="14" width="32">`;
|
||
}
|
||
}
|
||
buf += '</span> ';
|
||
if (gen >= 3) {
|
||
buf += '<span style="float:left;min-height:26px">';
|
||
if (species.abilities['1'] && (gen >= 4 || Dex.abilities.get(species.abilities['1']).gen === 3)) {
|
||
buf += '<span class="col twoabilitycol">' + species.abilities['0'] + '<br />' + species.abilities['1'] + '</span>';
|
||
} else {
|
||
buf += '<span class="col abilitycol">' + species.abilities['0'] + '</span>';
|
||
}
|
||
if (species.abilities['H'] && species.abilities['S']) {
|
||
buf += '<span class="col twoabilitycol' + (species.unreleasedHidden ? ' unreleasedhacol' : '') + '"><em>' + species.abilities['H'] + '<br />(' + species.abilities['S'] + ')</em></span>';
|
||
} else if (species.abilities['H']) {
|
||
buf += '<span class="col abilitycol' + (species.unreleasedHidden ? ' unreleasedhacol' : '') + '"><em>' + species.abilities['H'] + '</em></span>';
|
||
} else if (species.abilities['S']) {
|
||
// special case for Zygarde
|
||
buf += '<span class="col abilitycol"><em>(' + species.abilities['S'] + ')</em></span>';
|
||
} else {
|
||
buf += '<span class="col abilitycol"></span>';
|
||
}
|
||
buf += '</span>';
|
||
}
|
||
buf += '<span style="float:left;min-height:26px">';
|
||
buf += '<span class="col statcol"><em>HP</em><br />' + species.baseStats.hp + '</span> ';
|
||
buf += '<span class="col statcol"><em>Atk</em><br />' + species.baseStats.atk + '</span> ';
|
||
buf += '<span class="col statcol"><em>Def</em><br />' + species.baseStats.def + '</span> ';
|
||
if (gen <= 1) {
|
||
buf += '<span class="col statcol"><em>Spc</em><br />' + species.baseStats.spa + '</span> ';
|
||
} else {
|
||
buf += '<span class="col statcol"><em>SpA</em><br />' + species.baseStats.spa + '</span> ';
|
||
buf += '<span class="col statcol"><em>SpD</em><br />' + species.baseStats.spd + '</span> ';
|
||
}
|
||
buf += '<span class="col statcol"><em>Spe</em><br />' + species.baseStats.spe + '</span> ';
|
||
buf += '<span class="col bstcol"><em>BST<br />' + species.bst + '</em></span> ';
|
||
buf += '</span>';
|
||
buf += '</li>';
|
||
return `<div class="message"><ul class="utilichart">${buf}<li style="clear:both"></li></ul></div>`;
|
||
}
|
||
getDataMoveHTML(move: Move) {
|
||
let buf = `<ul class="utilichart"><li class="result">`;
|
||
buf += `<span class="col movenamecol"><a href="https://${Config.routes.dex}/moves/${move.id}">${move.name}</a></span> `;
|
||
// encoding is important for the ??? type icon
|
||
const encodedMoveType = encodeURIComponent(move.type);
|
||
buf += `<span class="col typecol"><img src="//${Config.routes.client}/sprites/types/${encodedMoveType}.png" alt="${move.type}" width="32" height="14">`;
|
||
buf += `<img src="//${Config.routes.client}/sprites/categories/${move.category}.png" alt="${move.category}" width="32" height="14"></span> `;
|
||
if (move.basePower) {
|
||
buf += `<span class="col labelcol"><em>Power</em><br>${typeof move.basePower === 'number' ? move.basePower : '—'}</span> `;
|
||
}
|
||
buf += `<span class="col widelabelcol"><em>Accuracy</em><br>${typeof move.accuracy === 'number' ? (move.accuracy + '%') : '—'}</span> `;
|
||
const basePP = move.pp || 1;
|
||
const pp = Math.floor(move.noPPBoosts ? basePP : basePP * 8 / 5);
|
||
buf += `<span class="col pplabelcol"><em>PP</em><br>${pp}</span> `;
|
||
buf += `<span class="col movedesccol">${move.shortDesc || move.desc}</span> `;
|
||
buf += `</li><li style="clear:both"></li></ul>`;
|
||
return buf;
|
||
}
|
||
getDataAbilityHTML(ability: Ability) {
|
||
let buf = `<ul class="utilichart"><li class="result">`;
|
||
buf += `<span class="col namecol"><a href="https://${Config.routes.dex}/abilities/${ability.id}">${ability.name}</a></span> `;
|
||
buf += `<span class="col abilitydesccol">${ability.shortDesc || ability.desc}</span> `;
|
||
buf += `</li><li style="clear:both"></li></ul>`;
|
||
return buf;
|
||
}
|
||
getDataItemHTML(item: Item) {
|
||
let buf = `<ul class="utilichart"><li class="result">`;
|
||
buf += `<span class="col itemiconcol"><psicon item="${item.id}"></span> <span class="col namecol"><a href="https://${Config.routes.dex}/items/${item.id}">${item.name}</a></span> `;
|
||
buf += `<span class="col itemdesccol">${item.shortDesc || item.desc}</span> `;
|
||
buf += `</li><li style="clear:both"></li></ul>`;
|
||
return buf;
|
||
}
|
||
|
||
/**
|
||
* Gets the dimension of the image at url. Returns 0x0 if the image isn't found, as well as the relevant error.
|
||
*/
|
||
getImageDimensions(url: string): Promise<{height: number, width: number}> {
|
||
return probe(url);
|
||
}
|
||
|
||
/**
|
||
* Normalize a message for the purposes of applying chat filters.
|
||
*
|
||
* Not used by PS itself, but feel free to use it in your own chat filters.
|
||
*/
|
||
normalize(message: string) {
|
||
message = message.replace(/'/g, '').replace(/[^A-Za-z0-9]+/g, ' ').trim();
|
||
if (!/[A-Za-z][A-Za-z]/.test(message)) {
|
||
message = message.replace(/ */g, '');
|
||
} else if (!message.includes(' ')) {
|
||
message = message.replace(/([A-Z])/g, ' $1').trim();
|
||
}
|
||
return ' ' + message.toLowerCase() + ' ';
|
||
}
|
||
|
||
/**
|
||
* Generates dimensions to fit an image at url into a maximum size of maxWidth x maxHeight,
|
||
* preserving aspect ratio.
|
||
*
|
||
* @return [width, height, resized]
|
||
*/
|
||
async fitImage(url: string, maxHeight = 300, maxWidth = 300): Promise<[number, number, boolean]> {
|
||
const {height, width} = await Chat.getImageDimensions(url);
|
||
|
||
if (width <= maxWidth && height <= maxHeight) return [width, height, false];
|
||
|
||
const ratio = Math.min(maxHeight / height, maxWidth / width);
|
||
|
||
return [Math.round(width * ratio), Math.round(height * ratio), true];
|
||
}
|
||
|
||
/**
|
||
* Notifies a targetUser that a user was blocked from reaching them due to a setting they have enabled.
|
||
*/
|
||
maybeNotifyBlocked(blocked: 'pm' | 'challenge' | 'invite', targetUser: User, user: User) {
|
||
const prefix = `|pm|&|${targetUser.getIdentity()}|/nonotify `;
|
||
const options = 'or change it in the <button name="openOptions" class="subtle">Options</button> menu in the upper right.';
|
||
if (blocked === 'pm') {
|
||
if (!targetUser.notified.blockPMs) {
|
||
targetUser.send(`${prefix}The user '${Utils.escapeHTML(user.name)}' attempted to PM you but was blocked. To enable PMs, use /unblockpms ${options}`);
|
||
targetUser.notified.blockPMs = true;
|
||
}
|
||
} else if (blocked === 'challenge') {
|
||
if (!targetUser.notified.blockChallenges) {
|
||
targetUser.send(`${prefix}The user '${Utils.escapeHTML(user.name)}' attempted to challenge you to a battle but was blocked. To enable challenges, use /unblockchallenges ${options}`);
|
||
targetUser.notified.blockChallenges = true;
|
||
}
|
||
} else if (blocked === 'invite') {
|
||
if (!targetUser.notified.blockInvites) {
|
||
targetUser.send(`${prefix}The user '${Utils.escapeHTML(user.name)}' attempted to invite you to a room but was blocked. To enable invites, use /unblockinvites.`);
|
||
targetUser.notified.blockInvites = true;
|
||
}
|
||
}
|
||
}
|
||
readonly formatText = formatText;
|
||
readonly linkRegex = linkRegex;
|
||
readonly stripFormatting = stripFormatting;
|
||
|
||
readonly filterWords: {[k: string]: FilterWord[]} = {};
|
||
readonly monitors: {[k: string]: Monitor} = {};
|
||
|
||
registerMonitor(id: string, entry: Monitor) {
|
||
if (!Chat.filterWords[id]) Chat.filterWords[id] = [];
|
||
Chat.monitors[id] = entry;
|
||
}
|
||
|
||
resolvePage(pageid: string, user: User, connection: Connection) {
|
||
return (new PageContext({pageid, user, connection, language: user.language!})).resolve();
|
||
}
|
||
};
|
||
|
||
// backwards compatibility; don't actually use these
|
||
// they're just there so forks have time to slowly transition
|
||
(Chat as any).escapeHTML = Utils.escapeHTML;
|
||
(Chat as any).html = Utils.html;
|
||
(Chat as any).splitFirst = Utils.splitFirst;
|
||
(CommandContext.prototype as any).can = CommandContext.prototype.checkCan;
|
||
(CommandContext.prototype as any).canTalk = CommandContext.prototype.checkChat;
|
||
(CommandContext.prototype as any).canBroadcast = CommandContext.prototype.checkBroadcast;
|
||
(CommandContext.prototype as any).canHTML = CommandContext.prototype.checkHTML;
|
||
(CommandContext.prototype as any).canEmbedURI = CommandContext.prototype.checkEmbedURI;
|
||
(CommandContext.prototype as any).canPMHTML = CommandContext.prototype.checkPMHTML;
|
||
(CommandContext.prototype as any).privatelyCan = CommandContext.prototype.privatelyCheckCan;
|
||
(CommandContext.prototype as any).requiresRoom = CommandContext.prototype.requireRoom;
|
||
|
||
/**
|
||
* Used by ChatMonitor.
|
||
*/
|
||
export interface FilterWord {
|
||
regex: RegExp;
|
||
word: string;
|
||
hits: number;
|
||
reason?: string;
|
||
publicReason?: string;
|
||
replacement?: string;
|
||
}
|
||
|
||
export type MonitorHandler = (
|
||
this: CommandContext,
|
||
line: FilterWord,
|
||
room: Room | null,
|
||
user: User,
|
||
message: string,
|
||
lcMessage: string,
|
||
isStaff: boolean
|
||
) => string | false | undefined;
|
||
export interface Monitor {
|
||
location: string;
|
||
punishment: string;
|
||
label: string;
|
||
condition?: string;
|
||
monitor?: MonitorHandler;
|
||
}
|