Minecraft-STC-Modpack/showdown/server/chat.js
2023-08-14 21:45:09 -04:00

2333 lines
89 KiB
JavaScript

"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var chat_exports = {};
__export(chat_exports, {
Chat: () => Chat,
CommandContext: () => CommandContext,
ErrorMessage: () => ErrorMessage,
Interruption: () => Interruption,
MessageContext: () => MessageContext,
PageContext: () => PageContext
});
module.exports = __toCommonJS(chat_exports);
var import_friends = require("./friends");
var import_lib = require("../lib");
var Artemis = __toESM(require("./artemis"));
var import_sim = require("../sim");
var pathModule = __toESM(require("path"));
var JSX = __toESM(require("./chat-jsx"));
var import_chat_formatter = require("./chat-formatter");
/**
* 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
*/
const LINK_WHITELIST = [
"*.pokemonshowdown.com",
"psim.us",
"smogtours.psim.us",
"*.smogon.com",
"*.pastebin.com",
"*.hastebin.com"
];
const MAX_MESSAGE_LENGTH = 1e3;
const BROADCAST_COOLDOWN = 20 * 1e3;
const MESSAGE_COOLDOWN = 5 * 60 * 1e3;
const MAX_PARSE_RECURSION = 10;
const VALID_COMMAND_TOKENS = "/!";
const BROADCAST_TOKEN = "!";
const PLUGIN_DATABASE_PATH = "./databases/chat-plugins.db";
const MAX_PLUGIN_LOADING_DEPTH = 3;
const ProbeModule = require("probe-image-size");
const probe = ProbeModule;
const EMOJI_REGEX = /[\p{Emoji_Modifier_Base}\p{Emoji_Presentation}\uFE0F]/u;
const TRANSLATION_DIRECTORY = pathModule.resolve(__dirname, "..", "translations");
class PatternTester {
constructor() {
this.elements = [];
this.fastElements = /* @__PURE__ */ new Set();
this.regexp = null;
}
fastNormalize(elem) {
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) {
for (const elem of elems) {
this.elements.push(elem);
if (/^[^ ^$?|()[\]]+ $/.test(elem)) {
this.fastElements.add(this.fastNormalize(elem));
}
}
this.update();
}
testCommand(text) {
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) {
if (!text.includes("\n"))
return null;
if (this.testCommand(text))
return text;
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;
}
}
class ErrorMessage extends Error {
constructor(message) {
super(message);
this.name = "ErrorMessage";
Error.captureStackTrace(this, ErrorMessage);
}
}
class Interruption extends Error {
constructor() {
super("");
this.name = "Interruption";
Error.captureStackTrace(this, ErrorMessage);
}
}
class MessageContext {
constructor(user, language = null) {
this.user = user;
this.language = language;
this.recursionDepth = 0;
}
splitOne(target) {
const commaIndex = target.indexOf(",");
if (commaIndex < 0) {
return [target.trim(), ""];
}
return [target.slice(0, commaIndex).trim(), target.slice(commaIndex + 1).trim()];
}
meansYes(text) {
switch (text.toLowerCase().trim()) {
case "on":
case "enable":
case "yes":
case "true":
case "allow":
case "1":
return true;
}
return false;
}
meansNo(text) {
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, atLeastOneTarget) {
const targets = typeof target === "string" ? target.split(",") : target;
if (!targets[0].trim())
targets.pop();
if (targets.length > (atLeastOneTarget ? 1 : 0)) {
const { dex: dex2, format: format2, isMatch } = this.extractFormat(targets[0].trim());
if (isMatch) {
targets.shift();
return { dex: dex2, format: format2, targets };
}
}
if (targets.length > 1) {
const { dex: dex2, format: format2, isMatch } = this.extractFormat(targets[targets.length - 1].trim());
if (isMatch) {
targets.pop();
return { dex: dex2, format: format2, targets };
}
}
const room = this.room;
const { dex, format } = this.extractFormat(room?.settings.defaultFormat || room?.battle?.format);
return { dex, format, targets };
}
extractFormat(formatOrMod) {
if (!formatOrMod) {
return { dex: import_sim.Dex.includeData(), format: null, isMatch: false };
}
const format = import_sim.Dex.formats.get(formatOrMod);
if (format.exists) {
return { dex: import_sim.Dex.forFormat(format), format, isMatch: true };
}
if (toID(formatOrMod) in import_sim.Dex.dexes) {
return { dex: import_sim.Dex.mod(toID(formatOrMod)).includeData(), format: null, isMatch: true };
}
return this.extractFormat();
}
splitUser(target, { exactName } = {}) {
const [inputUsername, rest] = this.splitOne(target).map((str) => str.trim());
const targetUser = Users.get(inputUsername, exactName);
return {
targetUser,
inputUsername,
targetUsername: targetUser ? targetUser.name : inputUsername,
rest
};
}
requireUser(target, options = {}) {
const { targetUser, targetUsername, rest } = this.splitUser(target, options);
if (!targetUser) {
throw new Chat.ErrorMessage(`The user "${targetUsername}" is offline or misspelled.`);
}
if (!options.allowOffline && !targetUser.connected) {
throw new Chat.ErrorMessage(`The user "${targetUsername}" is offline.`);
}
return { targetUser, rest };
}
getUserOrSelf(target, { exactName } = {}) {
if (!target.trim())
return this.user;
return Users.get(target, exactName);
}
tr(strings, ...keys) {
return Chat.tr(this.language, strings, ...keys);
}
}
class PageContext extends MessageContext {
constructor(options) {
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, target = null, room = null) {
if (!this.user.can(permission, target, room)) {
throw new Chat.ErrorMessage(`<h2>Permission denied.</h2>`);
}
return true;
}
privatelyCheckCan(permission, target = null, room = null) {
if (!this.user.can(permission, target, room)) {
this.pageDoesNotExist();
}
return true;
}
pageDoesNotExist() {
throw new Chat.ErrorMessage(`Page "${this.pageid}" not found`);
}
requireRoom(pageid) {
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) {
if (!pageid)
pageid = this.pageid;
const parts = pageid.split("-");
const room = Rooms.get(parts[2]) || null;
this.room = room;
return room;
}
setHTML(html) {
const roomid = this.room ? `[${this.room.roomid}] ` : "";
let content = `|title|${roomid}${this.title}
|pagehtml|${html}`;
if (!this.initialized) {
content = `|init|html
${content}`;
this.initialized = true;
}
this.send(content);
}
errorReply(message) {
this.setHTML(`<div class="pad"><p class="message-error">${message}</p></div>`);
}
send(content) {
this.connection.send(`>${this.pageid}
${content}`);
}
close() {
this.send("|deinit");
}
async resolve(pageid) {
if (pageid)
this.pageid = pageid;
const parts = this.pageid.split("-");
parts.shift();
if (!this.connection.openPages)
this.connection.openPages = /* @__PURE__ */ new Set();
this.connection.openPages.add(parts.join("-"));
let handler = Chat.pages;
while (handler) {
if (typeof handler === "function") {
break;
}
handler = handler[parts.shift() || "default"] || handler[""];
}
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 === "object" && res)
res = JSX.render(res);
if (typeof res === "string") {
this.setHTML(res);
res = void 0;
}
return res;
}
}
class CommandContext extends MessageContext {
constructor(options) {
super(
options.user,
options.room && options.room.settings.language ? options.room.settings.language : options.user.language
);
this.message = options.message || ``;
this.recursionDepth = options.recursionDepth || 0;
this.pmTarget = options.pmTarget || null;
this.room = options.room || null;
this.connection = options.connection;
this.cmd = options.cmd || "";
this.cmdToken = options.cmdToken || "";
this.target = options.target || ``;
this.fullCmd = options.fullCmd || "";
this.handler = null;
this.isQuiet = options.isQuiet || false;
this.bypassRoomCheck = options.bypassRoomCheck || false;
this.broadcasting = false;
this.broadcastToRoom = true;
this.broadcastPrefix = options.broadcastPrefix || "";
this.broadcastMessage = "";
}
// TODO: return should be void | boolean | Promise<void | boolean>
parse(msg, options = {}) {
if (typeof msg === "string") {
const subcontext = new CommandContext({
message: msg,
user: this.user,
connection: this.connection,
room: this.room,
pmTarget: this.pmTarget,
recursionDepth: this.recursionDepth + 1,
bypassRoomCheck: this.bypassRoomCheck,
...options
});
if (subcontext.recursionDepth > MAX_PARSE_RECURSION) {
throw new Error("Too much command recursion");
}
return subcontext.parse();
}
let message = 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.bypassRoomCheck && this.room && !(this.user.id in this.room.users)) {
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) {
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;
}
if (message && typeof message.then === "function") {
this.update();
return message.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);
message = true;
}
this.update();
return message;
}
sendChatMessage(message) {
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)) {
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)}|${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:
${message}
It needs to be sent to a user or room.`);
}
}
run(handler) {
if (typeof handler === "string")
handler = Chat.commands[handler];
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 = handler.call(this, this.target, this.room, this.user, this.connection, this.cmd, this.message);
if (result === void 0)
result = false;
return result;
}
checkFormat(room, user, message) {
if (!room)
return true;
if (!room.settings.filterStretching && !room.settings.filterCaps && !room.settings.filterEmojis && !room.settings.filterLinks) {
return true;
}
if (user.can("mute", null, room))
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.filterLinks) {
const bannedLinks = this.checkBannedLinks(message);
if (bannedLinks.length) {
throw new Chat.ErrorMessage(
`You have linked to ${bannedLinks.length > 1 ? "unrecognized external websites" : "an unrecognized external website"} (${bannedLinks.join(", ")}), 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.`);
}
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, user) {
if (!room?.settings.slowchat)
return true;
if (user.can("show", null, room))
return true;
const lastActiveSeconds = (Date.now() - user.lastMessageTime) / 1e3;
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, message) {
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, message);
}
checkGameFilter() {
if (!this.room?.game || !this.room.game.onChatMessage)
return;
return this.room.game.onChatMessage(this.message, this.user);
}
pmTransform(originalMessage, sender, receiver) {
if (!sender) {
if (this.room)
throw new Error(`Not a PM`);
sender = this.user;
receiver = this.pmTarget;
}
const targetIdentity = typeof receiver === "string" ? ` ${receiver}` : receiver ? receiver.getIdentity() : "~";
const prefix = `|pm|${sender.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(`|uhtml|`)) {
const [uhtmlid, html] = import_lib.Utils.splitFirst(message.slice(7), "|");
return prefix + `/uhtml ${uhtmlid},${html}`;
} else if (message.startsWith(`|uhtmlchange|`)) {
const [uhtmlid, html] = import_lib.Utils.splitFirst(message.slice(13), "|");
return prefix + `/uhtmlchange ${uhtmlid},${html}`;
} 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(`
`);
}
sendReply(data) {
if (this.isQuiet)
return;
if (this.broadcasting && this.broadcastToRoom) {
this.add(data);
} else {
if (!this.room) {
data = this.pmTransform(data);
this.connection.send(data);
} else {
this.connection.sendTo(this.room, data);
}
}
}
errorReply(message) {
if (this.bypassRoomCheck) {
return this.popupReply(
`|html|<strong class="message-error">${message.replace(/\n/ig, "<br />")}</strong>`
);
}
this.sendReply(`|error|` + message.replace(/\n/g, `
|error|`));
}
addBox(htmlContent) {
if (typeof htmlContent !== "string")
htmlContent = JSX.render(htmlContent);
this.add(`|html|<div class="infobox">${htmlContent}</div>`);
}
sendReplyBox(htmlContent) {
if (typeof htmlContent !== "string")
htmlContent = JSX.render(htmlContent);
this.sendReply(`|c|${this.room && this.broadcasting ? this.user.getIdentity() : "~"}|/raw <div class="infobox">${htmlContent}</div>`);
}
popupReply(message) {
this.connection.popup(message);
}
add(data) {
if (this.room) {
this.room.add(data);
} else {
this.send(data);
}
}
send(data) {
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) {
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) {
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) {
if (this.room) {
if (this.room.roomid === "staff") {
this.room.addByUser(this.user, `(${msg})`);
} else {
this.room.sendModsByUser(this.user, `(${msg})`);
this.roomlog(`(${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);
}
}
}
globalModlog(action, user = null, note = null, ip) {
const entry = {
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, user = null, note = null, options = {}) {
const entry = {
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(reason) {
if (!reason)
return { publicReason: "", privateReason: "" };
let publicReason = reason;
let privateReason = reason;
const targetLowercase = reason.toLowerCase();
if (targetLowercase.includes("spoiler:") || targetLowercase.includes("spoilers:")) {
const proofIndex = targetLowercase.indexOf(targetLowercase.includes("spoilers:") ? "spoilers:" : "spoiler:");
const proofOffset = targetLowercase.includes("spoilers:") ? 9 : 8;
const proof = reason.slice(proofIndex + proofOffset).trim();
publicReason = reason.slice(0, proofIndex).trim();
privateReason = `${publicReason}${proof ? ` (PROOF: ${proof})` : ""}`;
}
return { publicReason, privateReason };
}
roomlog(data) {
if (this.room)
this.room.roomlog(data);
}
stafflog(data) {
(Rooms.get("staff") || Rooms.lobby || this.room)?.roomlog(data);
}
addModAction(msg) {
if (this.room) {
this.room.addByUser(this.user, msg);
} else {
this.send(`|modaction|${msg}`);
}
}
update() {
if (this.room)
this.room.update();
}
filter(message) {
return Chat.filter(message, this);
}
statusfilter(status) {
return Chat.statusfilter(status, this.user);
}
checkCan(permission, target = null, room = null) {
if (!Users.Auth.hasPermission(this.user, permission, target, room, this.fullCmd)) {
throw new Chat.ErrorMessage(`${this.cmdToken}${this.fullCmd} - Access denied.`);
}
}
privatelyCheckCan(permission, target = null, room = 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(overrideCooldown, suppressMessage) {
if (this.broadcasting || !this.shouldBroadcast()) {
return true;
}
if (this.user.locked && !(this.room?.roomid.startsWith("help-") || this.pmTarget?.can("lock"))) {
this.errorReply(`You cannot broadcast this command's information while locked.`);
throw new Chat.ErrorMessage(`To see it for yourself, use: /${this.message.slice(1)}`);
}
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)}`);
}
const broadcastMessage = (suppressMessage || this.message).toLowerCase().replace(/[^a-z0-9\s!,]/g, "");
const cooldownMessage = overrideCooldown === true ? null : overrideCooldown || broadcastMessage;
if (cooldownMessage && this.room && this.room.lastBroadcast === cooldownMessage && this.room.lastBroadcastTime >= Date.now() - BROADCAST_COOLDOWN) {
throw new Chat.ErrorMessage(`You can't broadcast this because it was just broadcasted. If this was intentional, use !rebroadcast ${this.message}`);
}
const message = this.checkChat(suppressMessage || this.message);
if (!message) {
throw new Chat.ErrorMessage(`To see it for yourself, use: /${this.message.slice(1)}`);
}
this.message = message;
this.broadcastMessage = broadcastMessage;
return true;
}
runBroadcast(overrideCooldown, suppressMessage = null) {
if (this.broadcasting || !this.shouldBroadcast()) {
return true;
}
if (!this.broadcastMessage) {
this.checkBroadcast(overrideCooldown, suppressMessage);
}
this.broadcasting = true;
const message = `${this.broadcastPrefix}${suppressMessage || this.message}`;
if (this.pmTarget) {
this.sendReply(`|c~|${message}`);
} else {
this.sendReply(`|c|${this.user.getIdentity(this.room)}|${message}`);
}
if (this.room) {
this.language = this.room.settings.language || null;
if (overrideCooldown !== true) {
this.room.lastBroadcast = overrideCooldown || this.broadcastMessage;
this.room.lastBroadcastTime = Date.now();
}
}
return true;
}
checkChat(message = null, room = null, targetUser = null) {
if (!targetUser && this.pmTarget) {
targetUser = this.pmTarget;
}
if (targetUser) {
room = null;
} else if (!room) {
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>`);
if (user.locked === "#hostfilter") {
throw new Chat.ErrorMessage(this.tr`You are locked due to your proxy / VPN and can't talk in chat.`);
} else {
throw new Chat.ErrorMessage(this.tr`You are ${lockType} and can't talk in chat. ${lockExpiration}`);
}
}
if (!room.persist && !room.roomid.startsWith("help-") && !(user.registered || user.autoconfirmed)) {
this.sendReply(
this.tr`|html|<div class="message-error">You must be registered to chat in temporary rooms (like battles).</div>` + this.tr`You may register in the <button name="openOptions"><i class="fa fa-cog"></i> Options</button> menu.`
);
throw new Chat.Interruption();
}
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 (!this.bypassRoomCheck && !(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 (!(user.registered || user.autoconfirmed)) {
this.sendReply(
this.tr`|html|<div class="message-error">You must be registered to send private messages.</div>` + this.tr`You may register in the <button name="openOptions"><i class="fa fa-cog"></i> Options</button> menu.`
);
throw new Chat.Interruption();
}
if (targetUser.id !== user.id && !(targetUser.registered || targetUser.autoconfirmed)) {
throw new Chat.ErrorMessage(this.tr`That user is unregistered and cannot be PMed.`);
}
if (lockType && !targetUser.can("lock")) {
this.sendReply(`|html|<a href="view-help-request--appeal" class="button">${this.tr`Get help with this`}</a>`);
if (user.locked === "#hostfilter") {
throw new Chat.ErrorMessage(this.tr`You are locked due to your proxy / VPN and can only private message members of the global moderation team.`);
} else {
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)) {
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 (!this.checkCanPM(targetUser)) {
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 (!this.checkCanPM(user, targetUser)) {
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);
}
message = message.replace(
/[\u0300-\u036f\u0483-\u0489\u0610-\u0615\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06ED\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]{3,}/g,
""
);
if (/[\u3164\u115f\u1160\u239b-\u23b9]/.test(message)) {
throw new Chat.ErrorMessage(this.tr`Your message contains banned characters.`);
}
if (Config.restrictLinks && !user.autoconfirmed) {
if (this.checkBannedLinks(message).length && !(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?.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 (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 (Chat.filters.length) {
return this.filter(message);
}
return message;
}
checkCanPM(targetUser, user) {
if (!user)
user = this.user;
if (user.id === targetUser.id)
return true;
const setting = targetUser.settings.blockPMs;
if (user.can("lock") || !setting)
return true;
if (setting === true && !user.can("lock"))
return false;
const friends = targetUser.friends || /* @__PURE__ */ new Set();
if (setting === "friends")
return friends.has(user.id);
return Users.globalAuth.atLeast(user, setting);
}
checkPMHTML(targetUser) {
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.");
}
const friends = targetUser.friends || /* @__PURE__ */ new Set();
if (targetUser.settings.blockPMs && (targetUser.settings.blockPMs === true || targetUser.settings.blockPMs === "friends" && !friends.has(this.user.id) || !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;
}
checkBannedLinks(message) {
return (message.match(Chat.linkRegex) || []).filter((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}`));
});
}
/* eslint-enable @typescript-eslint/prefer-optional-chain */
checkEmbedURI(uri) {
if (uri.startsWith("https://"))
return uri;
if (uri.startsWith("//"))
return uri;
if (uri.startsWith("data:")) {
return uri;
} else {
throw new Chat.ErrorMessage("Image URLs must begin with 'https://' or 'data:'; 'http://' cannot be used.");
}
}
/**
* 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) {
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" \u2013\xA0See [[Design standard #2 <https://github.com/smogon/pokemon-showdown/blob/master/CONTRIBUTING.md#design-standards>]]');
}
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 : void 0;
const tagContent = tag.slice(isClosingTag ? 2 : 1, contentEndLoc).replace(/\s+/, " ").trim();
const tagNameEndIndex = tagContent.indexOf(" ");
const tagName = tagContent.slice(0, tagNameEndIndex >= 0 ? tagNameEndIndex : void 0).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)) {
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)) {
} 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;
}
/**
* This is to be used for commands that replicate other commands
* (for example, `/pm username, command` or `/msgroom roomid, command`)
* to ensure they do not crash with too many levels of recursion.
*/
checkRecursion() {
if (this.recursionDepth > 5) {
throw new Chat.ErrorMessage(`/${this.cmd} - Too much command recursion has occurred.`);
}
}
requireRoom(id) {
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(constructor, subGame = false) {
const room = this.requireRoom();
if (subGame) {
if (!room.subGame) {
throw new Chat.ErrorMessage(`This command requires a sub-game of ${constructor.name} (this room has no sub-game).`);
}
const game2 = room.getGame(constructor, subGame);
if (!game2) {
throw new Chat.ErrorMessage(`This command requires a sub-game of ${constructor.name} (this sub-game is ${room.subGame.title}).`);
}
return game2;
}
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);
if (!game) {
throw new Chat.ErrorMessage(`This command requires a game of ${constructor.name} (this game is ${room.game.title}).`);
}
return game;
}
requireMinorActivity(constructor) {
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);
if (!game) {
throw new Chat.ErrorMessage(`This command requires a ${constructor.name} (this minor activity is a(n) ${room.minorActivity.name}).`);
}
return game;
}
commandDoesNotExist() {
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) {
if (this.connection.openPages?.has(pageid)) {
this.parse(`/join view-${pageid}`);
}
}
closePage(pageid) {
for (const connection of this.user.connections) {
if (connection.openPages?.has(pageid)) {
connection.send(`>view-${pageid}
|deinit`);
connection.openPages.delete(pageid);
if (!connection.openPages.size) {
connection.openPages = null;
}
}
}
}
}
const Chat = new class {
constructor() {
this.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.
*/
this.MAX_TIMEOUT_DURATION = 2147483647;
this.Friends = new import_friends.FriendsDatabase();
this.PM = import_friends.PM;
this.multiLinePattern = new PatternTester();
this.destroyHandlers = [Artemis.destroy];
this.crqHandlers = {};
this.handlers = /* @__PURE__ */ Object.create(null);
/** The key is the name of the plugin. */
this.plugins = {};
/** Will be empty except during hotpatch */
this.oldPlugins = {};
this.roomSettings = [];
/*********************************************************
* Load chat filters
*********************************************************/
this.filters = [];
this.namefilters = [];
this.hostfilters = [];
this.loginfilters = [];
this.punishmentfilters = [];
this.nicknamefilters = [];
this.statusfilters = [];
/*********************************************************
* Translations
*********************************************************/
/** language id -> language name */
this.languages = /* @__PURE__ */ new Map();
/** language id -> (english string -> translated string) */
this.translations = /* @__PURE__ */ new Map();
/**
* SQL handler
*
* All chat plugins share one database.
* Chat.databaseReadyPromise will be truthy if the database is not yet ready.
*/
this.database = (0, import_lib.SQL)(module, {
file: "Config" in global && Config.nofswriting ? ":memory:" : PLUGIN_DATABASE_PATH,
processes: global.Config?.chatdbprocesses || 1
});
this.databaseReadyPromise = null;
this.MessageContext = MessageContext;
this.CommandContext = CommandContext;
this.PageContext = PageContext;
this.ErrorMessage = ErrorMessage;
this.Interruption = Interruption;
// JSX handling
this.JSX = JSX;
this.html = JSX.html;
this.h = JSX.h;
this.Fragment = JSX.Fragment;
this.packageData = {};
this.formatText = import_chat_formatter.formatText;
this.linkRegex = import_chat_formatter.linkRegex;
this.stripFormatting = import_chat_formatter.stripFormatting;
this.filterWords = {};
this.monitors = {};
void this.loadTranslations().then(() => {
Chat.translationsLoaded = true;
});
}
filter(message, context) {
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 !== void 0)
return output;
if (output !== void 0)
message = output;
}
return message;
}
namefilter(name, user) {
if (!Config.disablebasicnamefilter) {
name = name.replace(
// eslint-disable-next-line no-misleading-character-class
/[^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,
""
);
name = name.replace(/[\u00a1\u2580-\u2590\u25A0\u25Ac\u25AE\u25B0\u2a0d\u534d\u5350]/g, "");
if (name.includes("@") && name.includes("."))
return "";
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, "");
const nameSymbols = name.replace(
/[^\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2090-\u23FA\u2500-\u2BD1]+/g,
""
);
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(
// eslint-disable-next-line no-misleading-character-class
/[\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]+/, "");
name = name.replace(/@/g, "");
if (/[A-Za-z0-9]/.test(name.slice(18))) {
name = name.replace(/[^A-Za-z0-9]+/g, "");
} else {
name = name.slice(0, 18);
}
name = import_sim.Dex.getName(name);
for (const curFilter of Chat.namefilters) {
name = curFilter(name, user);
if (!name)
return "";
}
return name;
}
hostfilter(host, user, connection, hostType) {
for (const curFilter of Chat.hostfilters) {
curFilter(host, user, connection, hostType);
}
}
loginfilter(user, oldUser, usertype) {
for (const curFilter of Chat.loginfilters) {
curFilter(user, oldUser, usertype);
}
}
punishmentfilter(user, punishment) {
for (const curFilter of Chat.punishmentfilters) {
curFilter(user, punishment);
}
}
nicknamefilter(nickname, user) {
for (const curFilter of Chat.nicknamefilters) {
const filtered = curFilter(nickname, user);
if (filtered === false)
return false;
if (!filtered)
return "";
}
return nickname;
}
statusfilter(status, user) {
status = status.replace(/\|/g, "");
for (const curFilter of Chat.statusfilters) {
status = curFilter(status, user);
if (!status)
return "";
}
return status;
}
async loadTranslations() {
const directories = await (0, import_lib.FS)(TRANSLATION_DIRECTORY).readdir();
Chat.languages.set("english", "English");
for (const dirname of directories) {
if (/[^a-z0-9]/.test(dirname))
continue;
const dir = (0, import_lib.FS)(`${TRANSLATION_DIRECTORY}/${dirname}`);
const languageID = import_sim.Dex.toID(dirname);
const files = await dir.readdir();
for (const filename of files) {
if (!filename.endsWith(".js"))
continue;
const content = require(`${TRANSLATION_DIRECTORY}/${dirname}/${filename}`).translations;
if (!Chat.translations.has(languageID)) {
Chat.translations.set(languageID, /* @__PURE__ */ 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 = [];
const valLabels = [];
const newKey = key.replace(/\${.+?}/g, (str) => {
keyLabels.push(str);
return "${}";
}).replace(/\[TN: ?.+?\]/g, "");
const val = content.strings[key].replace(/\${.+?}/g, (str) => {
valLabels.push(str);
return "${}";
}).replace(/\[TN: ?.+?\]/g, "");
translationsSoFar.set(newKey, [val, keyLabels, valLabels]);
}
}
}
if (!Chat.languages.has(languageID)) {
Chat.languages.set(languageID, "Unknown Language");
}
}
}
tr(language, strings = "", ...keys) {
if (!language)
language = "english";
const trString = typeof strings === "string" ? strings : strings.join("${}");
if (Chat.translationsLoaded && !Chat.translations.has(language)) {
throw new Error(`Trying to translate to a nonexistent language: ${language}`);
}
if (!strings.length) {
return (fStrings, ...fKeys) => Chat.tr(language, fStrings, ...fKeys);
}
const entry = Chat.translations.get(language)?.get(trString);
let [translated, keyLabels, valLabels] = entry || ["", [], []];
if (!translated)
translated = trString;
if (keys.length) {
let reconstructed = "";
const left = 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;
}
async prepareDatabase() {
if (!import_friends.PM.isParentProcess)
return;
if (!Config.usesqlite)
return;
const { hasDBInfo } = await this.database.get(
`SELECT count(*) AS hasDBInfo FROM sqlite_master WHERE type = 'table' AND name = 'db_info'`
);
if (!hasDBInfo)
await this.database.runFile("./databases/schemas/chat-plugins.sql");
const result = await this.database.get(
`SELECT value as curVersion FROM db_info WHERE key = 'version'`
);
const curVersion = parseInt(result.curVersion);
if (!curVersion)
throw new Error(`db_info table is present, but schema version could not be parsed`);
const migrationsFolder = "./databases/migrations/chat-plugins";
const migrationsToRun = [];
for (const migrationFile of await (0, import_lib.FS)(migrationsFolder).readdir()) {
const migrationVersion = parseInt(/v(\d+)\.sql$/.exec(migrationFile)?.[1] || "");
if (!migrationVersion)
continue;
if (migrationVersion > curVersion) {
migrationsToRun.push({ version: migrationVersion, file: migrationFile });
Monitor.adminlog(`Pushing to migrationsToRun: ${migrationVersion} at ${migrationFile} - mainModule ${process.mainModule === module} !process.send ${!process.send}`);
}
}
import_lib.Utils.sortBy(migrationsToRun, ({ version }) => version);
for (const { file } of migrationsToRun) {
await this.database.runFile(pathModule.resolve(migrationsFolder, file));
}
Chat.destroyHandlers.push(() => void Chat.database?.destroy());
}
/**
* 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, room, user, 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, context) {
const timeUsed = Date.now() - start;
if (timeUsed < 1e3)
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, user, pmTarget, onlyRecipient = 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;
}
getPluginName(file) {
const nameWithExt = pathModule.relative(__dirname, file).replace(/^chat-(?:commands|plugins)./, "");
let name = nameWithExt.slice(0, nameWithExt.lastIndexOf("."));
if (name.endsWith("/index"))
name = name.slice(0, -6);
return name;
}
loadPluginFile(file) {
if (!file.endsWith(".js"))
return;
this.loadPlugin(require(file), this.getPluginName(file));
}
loadPluginDirectory(dir, depth = 0) {
for (const file of (0, import_lib.FS)(dir).readdirSync()) {
const path = pathModule.resolve(dir, file);
if ((0, import_lib.FS)(path).isDirectorySync()) {
depth++;
if (depth > MAX_PLUGIN_LOADING_DEPTH)
continue;
this.loadPluginDirectory(path, depth);
} else {
try {
this.loadPluginFile(path);
} catch (e) {
Monitor.crashlog(e, "A loading chat plugin");
continue;
}
}
}
}
annotateCommands(commandTable, namespace = "") {
for (const cmd2 in commandTable) {
const entry = commandTable[cmd2];
if (typeof entry === "object") {
this.annotateCommands(entry, `${namespace}${cmd2} `);
}
if (typeof entry === "string") {
const base = commandTable[entry];
if (!base)
continue;
if (!base.aliases)
base.aliases = [];
if (!base.aliases.includes(cmd2))
base.aliases.push(cmd2);
continue;
}
if (typeof entry !== "function")
continue;
const handlerCode = entry.toString();
entry.requiresRoom = /requireRoom\((?:'|"|`)(.*?)(?:'|"|`)/.exec(handlerCode)?.[1] || /this\.requireRoom\(/.test(handlerCode);
entry.hasRoomPermissions = /\bthis\.(checkCan|can)\([^,)\n]*, [^,)\n]*,/.test(handlerCode);
entry.broadcastable = cmd2.endsWith("help") || /\bthis\.(?:(check|can|run|should)Broadcast)\(/.test(handlerCode);
entry.isPrivate = /\bthis\.(?:privately(Check)?Can|commandDoesNotExist)\(/.test(handlerCode);
entry.requiredPermission = /this\.(?:checkCan|privately(?:Check)?Can)\(['`"]([a-zA-Z0-9]+)['"`](\)|, )/.exec(handlerCode)?.[1];
if (!entry.aliases)
entry.aliases = [];
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;
}
}
entry.cmd = cmd2;
entry.fullCmd = `${namespace}${cmd2}`;
}
return commandTable;
}
loadPlugin(plugin, name) {
plugin = { ...plugin };
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) {
if (!Chat.handlers["onRenameRoom"])
Chat.handlers["onRenameRoom"] = [];
Chat.handlers["onRenameRoom"].push(plugin.onRenameRoom);
}
if (plugin.onRoomClose) {
if (!Chat.handlers["onRoomClose"])
Chat.handlers["onRoomClose"] = [];
Chat.handlers["onRoomClose"].push(plugin.onRoomClose);
}
if (plugin.handlers) {
for (const handlerName in plugin.handlers) {
if (!Chat.handlers[handlerName])
Chat.handlers[handlerName] = [];
Chat.handlers[handlerName].push(plugin.handlers[handlerName]);
}
}
Chat.plugins[name] = plugin;
}
loadPlugins(oldPlugins) {
if (Chat.commands)
return;
if (oldPlugins)
Chat.oldPlugins = oldPlugins;
void (0, import_lib.FS)("package.json").readIfExists().then((data) => {
if (data)
Chat.packageData = JSON.parse(data);
});
Chat.commands = /* @__PURE__ */ Object.create(null);
Chat.pages = /* @__PURE__ */ Object.create(null);
this.loadPluginDirectory("dist/server/chat-commands");
Chat.baseCommands = Chat.commands;
Chat.basePages = Chat.pages;
Chat.commands = Object.assign(/* @__PURE__ */ Object.create(null), Chat.baseCommands);
Chat.pages = Object.assign(/* @__PURE__ */ Object.create(null), Chat.basePages);
this.loadPlugin(Config, "config");
this.loadPlugin(Tournaments, "tournaments");
this.loadPluginDirectory("dist/server/chat-plugins");
Chat.oldPlugins = {};
import_lib.Utils.sortBy(Chat.filters, (filter) => -(filter.priority || 0));
}
destroy() {
for (const handler of Chat.destroyHandlers) {
handler();
}
}
runHandlers(name, ...args) {
const handlers = this.handlers[name];
if (!handlers)
return;
for (const h of handlers) {
void h.call(this, ...args);
}
}
handleRoomRename(oldID, newID, room) {
Chat.runHandlers("onRenameRoom", oldID, newID, room);
}
handleRoomClose(roomid, user, connection) {
Chat.runHandlers("onRoomClose", roomid, user, connection, roomid.startsWith("view-"));
}
/**
* 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, recursing = false) {
if (!message.trim())
return null;
if (message.startsWith(`>> `)) {
message = `/eval ${message.slice(3)}`;
} else if (message.startsWith(`>>> `)) {
message = `/evalbattle ${message.slice(4)}`;
} else if (message.startsWith(">>sql ")) {
message = `/evalsql ${message.slice(6)}`;
} 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 [cmd2, target] = import_lib.Utils.splitFirst(message.slice(1), " ");
cmd2 = cmd2.toLowerCase();
if (cmd2.endsWith(","))
cmd2 = cmd2.slice(0, -1);
let curCommands = Chat.commands;
let commandHandler;
let fullCmd = cmd2;
let prevCmdName = "";
do {
if (cmd2 in curCommands) {
commandHandler = curCommands[cmd2];
} else {
commandHandler = void 0;
}
if (typeof commandHandler === "string") {
commandHandler = curCommands[commandHandler];
} else if (Array.isArray(commandHandler) && !recursing) {
return this.parseCommand(cmdToken + "help " + fullCmd.slice(0, -4), true);
}
if (commandHandler && typeof commandHandler === "object") {
[cmd2, target] = import_lib.Utils.splitFirst(target, " ");
cmd2 = cmd2.toLowerCase();
prevCmdName = fullCmd;
fullCmd += " " + cmd2;
curCommands = commandHandler;
}
} while (commandHandler && typeof commandHandler === "object");
if (!commandHandler && (curCommands.default || curCommands[""])) {
commandHandler = curCommands.default || curCommands[""];
fullCmd = prevCmdName;
target = `${cmd2}${target ? ` ${target}` : ""}`;
cmd2 = fullCmd.split(" ").shift();
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: cmd2,
cmdToken,
target,
fullCmd,
handler: commandHandler
};
}
allCommands(table = Chat.commands) {
const results = [];
for (const cmd2 in table) {
const handler = table[cmd2];
if (Array.isArray(handler) || !handler || ["string", "boolean"].includes(typeof handler)) {
continue;
}
if (typeof handler === "object") {
results.push(...this.allCommands(handler));
continue;
}
results.push(handler);
}
if (table !== Chat.commands)
return results;
return results.filter((handler, i) => results.indexOf(handler) === i);
}
/**
* Strips HTML from a string.
*/
stripHTML(htmlContent) {
if (!htmlContent)
return "";
return htmlContent.replace(/<[^>]*>/g, "");
}
/**
* Validates input regex and ensures it won't crash.
*/
validateRegex(word) {
word = word.trim();
if (word.endsWith("|") && !word.endsWith("\\|") || word.startsWith("|")) {
throw new Chat.ErrorMessage(`Your regex was rejected because it included an unterminated |.`);
}
try {
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, 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, pluralSuffix, 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, options = {}) {
const human = options.human;
let parts = [
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, options = {}) {
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;
}
if (positiveIndex + precision < parts.length && precision > 0 && positiveIndex >= 0) {
if (parts[positiveIndex + precision] >= roundingBoundaries[positiveIndex + precision - 1]) {
parts[positiveIndex + precision - 1]++;
}
}
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, conjunction = "and") {
if (!arr.length)
return "";
if (arr.length === 1)
return arr[0];
if (arr.length === 2)
return `${arr[0]} ${conjunction.trim()} ${arr[1]}`;
return `${arr.slice(0, -1).join(", ")}, ${conjunction.trim()} ${arr.slice(-1)[0]}`;
}
/**
* Takes an array and turns it into a sentence string by adding commas and the word "or"
*/
toOrList(arr) {
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 `&#10;`.
*
* 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) {
htmlContent = htmlContent.replace(/<[^>]*>/g, (tag) => tag.replace(/\n/g, " "));
htmlContent = htmlContent.replace(/\n/g, "&#10;");
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, isCode, cutoff = 3) {
const params = str.slice(+str.startsWith("\n")).split("\n");
const output = [];
for (const [i, param] of params.entries()) {
if (output.length < cutoff && param.length > 80 && cutoff > 2)
cutoff--;
if (param.length > cutoff * 160 && i < cutoff)
cutoff = i;
output.push(import_lib.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, cutoff) {
return Chat.getReadmoreBlock(str, true, cutoff);
}
getDataPokemonHTML(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 || import_sim.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"]) {
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) {
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> `;
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 : "\u2014"}</span> `;
}
buf += `<span class="col widelabelcol"><em>Accuracy</em><br>${typeof move.accuracy === "number" ? move.accuracy + "%" : "\u2014"}</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) {
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) {
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) {
return probe(url);
}
parseArguments(str, delim = ",", opts = { useIDs: true }) {
const result = {};
for (const part of str.split(delim)) {
let [key, val] = import_lib.Utils.splitFirst(part, opts.paramDelim || (opts.paramDelim = "=")).map((f) => f.trim());
if (opts.useIDs)
key = toID(key);
if (!toID(key) || !opts.allowEmpty && !toID(val)) {
throw new Chat.ErrorMessage(`Invalid option ${part}. Must be in [key]${opts.paramDelim}[value] format.`);
}
if (!result[key])
result[key] = [];
result[key].push(val);
}
return result;
}
/**
* 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) {
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, maxHeight = 300, maxWidth = 300) {
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];
}
refreshPageFor(pageid, roomid, checkPrefix = false, ignoreUsers = null) {
const room = Rooms.get(roomid);
if (!room)
return false;
for (const id in room.users) {
if (ignoreUsers?.includes(id))
continue;
const u = room.users[id];
for (const conn of u.connections) {
if (conn.openPages) {
for (const page of conn.openPages) {
if (checkPrefix ? page.startsWith(pageid) : page === pageid) {
void this.parse(`/j view-${page}`, room, u, conn);
}
}
}
}
}
}
/**
* Notifies a targetUser that a user was blocked from reaching them due to a setting they have enabled.
*/
maybeNotifyBlocked(blocked, targetUser, 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 '${import_lib.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 '${import_lib.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 '${import_lib.Utils.escapeHTML(user.name)}' attempted to invite you to a room but was blocked. To enable invites, use /unblockinvites.`);
targetUser.notified.blockInvites = true;
}
}
}
/** Helper function to ensure no state issues occur when regex testing for links. */
isLink(possibleUrl) {
this.linkRegex.lastIndex = -1;
return this.linkRegex.test(possibleUrl);
}
registerMonitor(id, entry) {
if (!Chat.filterWords[id])
Chat.filterWords[id] = [];
Chat.monitors[id] = entry;
}
resolvePage(pageid, user, connection) {
return new PageContext({ pageid, user, connection, language: user.language }).resolve();
}
}();
Chat.escapeHTML = import_lib.Utils.escapeHTML;
Chat.splitFirst = import_lib.Utils.splitFirst;
CommandContext.prototype.can = CommandContext.prototype.checkCan;
CommandContext.prototype.canTalk = CommandContext.prototype.checkChat;
CommandContext.prototype.canBroadcast = CommandContext.prototype.checkBroadcast;
CommandContext.prototype.canHTML = CommandContext.prototype.checkHTML;
CommandContext.prototype.canEmbedURI = CommandContext.prototype.checkEmbedURI;
CommandContext.prototype.privatelyCan = CommandContext.prototype.privatelyCheckCan;
CommandContext.prototype.requiresRoom = CommandContext.prototype.requireRoom;
CommandContext.prototype.targetUserOrSelf = function(target, exactName) {
const user = this.getUserOrSelf(target, exactName);
this.targetUser = user;
this.inputUsername = target;
this.targetUsername = user?.name || target;
return user;
};
CommandContext.prototype.splitTarget = function(target, exactName) {
const { targetUser, inputUsername, targetUsername, rest } = this.splitUser(target, exactName);
this.targetUser = targetUser;
this.inputUsername = inputUsername;
this.targetUsername = targetUsername;
return rest;
};
if (!process.send) {
Chat.database.spawn(Config.chatdbprocesses || 1);
Chat.databaseReadyPromise = Chat.prepareDatabase();
} else if (process.mainModule === module) {
global.Monitor = {
crashlog(error, source = "A chat child process", details = null) {
const repr = JSON.stringify([error.name, error.message, source, details]);
process.send(`THROW
@!!@${repr}
${error.stack}`);
}
};
process.on("uncaughtException", (err) => {
Monitor.crashlog(err, "A chat database process");
});
process.on("unhandledRejection", (err) => {
Monitor.crashlog(err, "A chat database process");
});
global.Config = require("./config-loader").Config;
import_lib.Repl.start("chat-db", (cmd) => eval(cmd));
}
//# sourceMappingURL=chat.js.map