`);
}
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) {
if (this.room && !this.room.roomid.endsWith("staff")) {
msg = msg.replace(IPTools.ipRegex, "");
}
this.privateModAction(msg);
if (this.room?.roomid !== "staff") {
Rooms.get("staff")?.addByUser(this.user, `${this.room ? `<<${this.room.roomid}>>` : ``} ${msg}`).update();
}
}
addGlobalModAction(msg) {
if (this.room && !this.room.roomid.endsWith("staff")) {
msg = msg.replace(IPTools.ipRegex, "");
}
this.addModAction(msg);
if (this.room?.roomid !== "staff") {
Rooms.get("staff")?.addByUser(this.user, `${this.room ? `<<${this.room.roomid}>>` : ``} ${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, this.cmdToken)) {
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, this.cmdToken)) {
return true;
}
this.commandDoesNotExist();
}
canUseConsole() {
if (!this.user.hasConsoleAccess(this.connection)) {
throw new Chat.ErrorMessage(
(this.cmdToken + this.fullCmd).trim() + " - 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.cmd, this.cmdToken)) {
const perm = this.room.settings.permissions?.[`!${this.cmd}`];
const atLeast = perm ? `at least rank ${perm}` : "voiced";
this.errorReply(`You need to be ${atLeast} 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|${this.tr`Get help with this`}`);
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|
You must be registered to chat in temporary rooms (like battles).
` + this.tr`You may register in the 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") {
this.errorReply(
this.tr`Moderated chat is set. To speak in this room, your account must be autoconfirmed, which means being registered for at least one week and winning at least one rated game (any game started through the 'Battle!' button).`
);
if (!user.registered) {
this.sendReply(this.tr`|html|You may register in the menu.`);
}
throw new Chat.Interruption();
}
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|
You must be registered to send private messages.
` + this.tr`You may register in the 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|${this.tr`Get help with this`}`);
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 help ticket`}`);
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 (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}`));
});
}
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.?]]');
}
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: `);
throw new Chat.ErrorMessage(`2. Sending a message to a Bot: `);
}
}
}
}
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;
}
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).trim()}" 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.PrivateMessages = import_private_messages.PrivateMessages;
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: global.Config?.nofswriting ? ":memory:" : PLUGIN_DATABASE_PATH,
processes: global.Config?.chatdbprocesses
});
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 (process.send)
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(),
() => Chat.PrivateMessages.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);
}
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 use a 12-hour clock
*/
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}`.padStart(2, "0"));
return parts.slice(0, 3).join("-") + " " + parts.slice(3, 6).join(":") + (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}`.padStart(2, "0")).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
*
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) {
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, isCode, cutoff = 3) {
const params = str.slice(str.startsWith("\n") ? 1 : 0).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 `${output.slice(0, cutoff).join(" ")}${output.slice(cutoff).join(" ")}`;
} else {
const tag = isCode ? `code` : `div`;
return `<${tag} style="white-space: pre-wrap; display: table; tab-size: 3">${output.join(" ")}${tag}>`;
}
}
getReadmoreCodeBlock(str, cutoff) {
return Chat.getReadmoreBlock(str, true, cutoff);
}
getDataPokemonHTML(species, gen = 8, tier = "") {
let buf = '