/** * Chat * Pokemon Showdown - http://pokemonshowdown.com/ * * This handles chat and chat commands sent from users to chatrooms * and PMs. The main function you're looking for is Chat.parse * (scroll down to its definition for details) * * Individual commands are put in: * chat-commands/ - "core" commands that shouldn't be modified * chat-plugins/ - other commands that can be safely modified * * The command API is (mostly) documented in chat-plugins/COMMANDS.md * * @license MIT */ /* To reload chat commands: /hotpatch chat */ 'use strict'; export type PageHandler = (this: PageContext, query: string[], user: User, connection: Connection) => Promise | string | null | void; export interface PageTable { [k: string]: PageHandler | PageTable; } export type ChatHandler = ( this: CommandContext, target: string, room: ChatRoom | GameRoom, user: User, connection: Connection, cmd: string, message: string ) => void; export interface ChatCommands { [k: string]: ChatHandler | string | string[] | true | ChatCommands; } export type SettingsHandler = ( room: BasicChatRoom, user: User, connection: Connection ) => { label: string, permission: boolean | string, // button label, command | disabled options: [string, string | true][], }; /** * Chat filters can choose to: * 1. return false OR null - to not send a user's message * 2. return an altered string - to alter a user's message * 3. return undefined to send the original message through */ export type ChatFilter = ( this: CommandContext, message: string, user: User, room: ChatRoom | GameRoom | null, connection: Connection, targetUser: User | null, originalMessage: string ) => string | false | null | undefined; export type NameFilter = (name: string, user: User) => string; export type StatusFilter = (status: string, user: User) => string; export type LoginFilter = (user: User, oldUser: User | null, userType: string) => void; export type HostFilter = (host: string, user: User, connection: Connection, hostType: string) => void; const LINK_WHITELIST = [ '*.pokemonshowdown.com', 'psim.us', 'smogtours.psim.us', '*.smogon.com', '*.pastebin.com', '*.hastebin.com', ]; const MAX_MESSAGE_LENGTH = 300; const BROADCAST_COOLDOWN = 20 * 1000; const MESSAGE_COOLDOWN = 5 * 60 * 1000; const MAX_PARSE_RECURSION = 10; const VALID_COMMAND_TOKENS = '/!'; const BROADCAST_TOKEN = '!'; const TRANSLATION_DIRECTORY = 'translations/'; import { FS } from '../lib/fs'; import { formatText, linkRegex, stripFormatting } from './chat-formatter'; // @ts-ignore no typedef available import ProbeModule = require('probe-image-size'); const probe: (url: string) => Promise<{width: number, height: number}> = ProbeModule; const emojiRegex = /[\p{Emoji_Modifier_Base}\p{Emoji_Presentation}\uFE0F]/u; class PatternTester { // This class sounds like a RegExp // In fact, one could in theory implement it as a RegExp subclass // However, ES2016 RegExp subclassing is a can of worms, and it wouldn't allow us // to tailor the test method for fast command parsing. readonly elements: string[]; readonly fastElements: Set; regexp: RegExp | null; constructor() { this.elements = []; this.fastElements = new Set(); this.regexp = null; } fastNormalize(elem: string) { return elem.slice(0, -1); } update() { const slowElements = this.elements.filter(elem => !this.fastElements.has(this.fastNormalize(elem))); if (slowElements.length) { this.regexp = new RegExp('^(' + slowElements.map(elem => '(?:' + elem + ')').join('|') + ')', 'i'); } } register(...elems: string[]) { for (const elem of elems) { this.elements.push(elem); if (/^[^ ^$?|()[\]]+ $/.test(elem)) { this.fastElements.add(this.fastNormalize(elem)); } } this.update(); } testCommand(text: string) { const spaceIndex = text.indexOf(' '); if (this.fastElements.has(spaceIndex >= 0 ? text.slice(0, spaceIndex) : text)) { return true; } if (!this.regexp) return false; return this.regexp.test(text); } test(text: string) { if (!text.includes('\n')) return null; if (this.testCommand(text)) return text; // The PM matching is a huge mess, and really needs to be replaced with // the new multiline command system soon. const pmMatches = /^(\/(?:pm|w|whisper|msg) [^,]*, ?)(.*)/i.exec(text); if (pmMatches && this.testCommand(pmMatches[2])) { if (text.split('\n').every(line => line.startsWith(pmMatches[1]))) { return text.replace(/\n\/(?:pm|w|whisper|msg) [^,]*, ?/g, '\n'); } return text; } return null; } } /********************************************************* * Parser *********************************************************/ // These classes need to be declared here because they aren't hoisted class MessageContext { readonly user: User; language: string | null; recursionDepth: number; constructor(user: User, language: string | null = null) { this.user = user; this.language = language; this.recursionDepth = 0; } splitOne(target: string) { const commaIndex = target.indexOf(','); if (commaIndex < 0) { return [target.trim(), '']; } return [target.slice(0, commaIndex).trim(), target.slice(commaIndex + 1).trim()]; } meansYes(text: string) { switch (text.toLowerCase().trim()) { case 'on': case 'enable': case 'yes': case 'true': return true; } return false; } meansNo(text: string) { switch (text.toLowerCase().trim()) { case 'off': case 'disable': case 'no': case 'false': return true; } return false; } tr(strings: TemplateStringsArray | string, ...keys: any[]) { return Chat.tr(this.language, strings, ...keys); } } export class PageContext extends MessageContext { readonly connection: Connection; room: Room; pageid: string; initialized: boolean; title: string; constructor(options: {pageid: string, user: User, connection: Connection, language?: string}) { super(options.user, options.language); this.connection = options.connection; this.room = Rooms.get('global')!; this.pageid = options.pageid; this.initialized = false; this.title = 'Page'; } can(permission: string, target: string | User | null = null, room: BasicChatRoom | null = null) { if (!this.user.can(permission, target, room)) { this.send(`

Permission denied.

`); return false; } return true; } extractRoom(pageid?: string) { if (!pageid) pageid = this.pageid; const parts = pageid.split('-'); // Since we assume pageids are all in the form of view-pagename-roomid // if someone is calling this function, so this is the only case we cover (for now) const room = Rooms.get(parts[2]); if (!room) { this.send(`

Invalid room.

`); return false; } this.room = room; return room.roomid; } send(content: string) { if (!content.startsWith('|deinit')) { const roomid = this.room !== Rooms.global ? `[${this.room.roomid}] ` : ''; if (!this.initialized) { content = `|init|html\n|title|${roomid}${this.title}\n|pagehtml|${content}`; this.initialized = true; } else { content = `|title|${roomid}${this.title}\n|pagehtml|${content}`; } } this.connection.send(`>${this.pageid}\n${content}`); } close() { this.send('|deinit'); } async resolve(pageid?: string) { if (pageid) this.pageid = pageid; const parts = this.pageid.split('-'); let handler: PageHandler | PageTable = Chat.pages; parts.shift(); while (handler) { if (typeof handler === 'function') { let res = await handler.bind(this)(parts, this.user, this.connection); if (typeof res === 'string') { this.send(res); res = undefined; } return res; } handler = handler[parts.shift() || 'default']; } } } export class CommandContext extends MessageContext { message: string; pmTarget: User | null; room: Room; connection: Connection; cmd: string; cmdToken: string; target: string; fullCmd: string; broadcasting: boolean; broadcastToRoom: boolean; broadcastMessage: string; targetUser: User | null; targetUsername: string; inputUsername: string; constructor( options: {message: string, room: Room, user: User, connection: Connection} & Partial<{pmTarget: User | null, cmd: string, cmdToken: string, target: string, fullCmd: string}> ) { super(options.user, options.room && options.room.language ? options.room.language : options.user.language); this.message = options.message || ``; // message context this.pmTarget = options.pmTarget || null; this.room = options.room || null; this.connection = options.connection; // command context this.cmd = options.cmd || ''; this.cmdToken = options.cmdToken || ''; this.target = options.target || ``; this.fullCmd = options.fullCmd || ''; // broadcast context this.broadcasting = false; this.broadcastToRoom = true; this.broadcastMessage = ''; // target user this.targetUser = null; this.targetUsername = ""; this.inputUsername = ""; } parse(msg?: string): any { if (typeof msg === 'string') { // spawn subcontext const subcontext = new CommandContext(this); subcontext.recursionDepth++; if (subcontext.recursionDepth > MAX_PARSE_RECURSION) { throw new Error("Too much command recursion"); } subcontext.message = msg; return subcontext.parse(); } let message: any = this.message; const commandHandler = this.splitCommand(message); if (this.user.statusType === 'idle') this.user.setStatusType('online'); if (typeof commandHandler === 'function') { message = this.run(commandHandler); } else { if (commandHandler === '!') { if (this.room === Rooms.global) { return this.popupReply(`You tried use "${message}" as a global command, but it is not a global command.`); } else if (this.room) { return this.popupReply(`You tried to send "${message}" to the room "${this.room.roomid}" but it failed because you were not in that room.`); } return this.errorReply(`The command "${this.cmdToken}${this.fullCmd}" is unavailable in private messages. To send a message starting with "${this.cmdToken}${this.fullCmd}", type "${this.cmdToken}${this.cmdToken}${this.fullCmd}".`); } if (this.cmdToken) { // To guard against command typos, show an error message if (this.shouldBroadcast()) { if (/[a-z0-9]/.test(this.cmd.charAt(0))) { return this.errorReply(`The command "${this.cmdToken}${this.fullCmd}" does not exist.`); } } else { return this.errorReply(`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}".`); } } else if (!VALID_COMMAND_TOKENS.includes(message.charAt(0)) && VALID_COMMAND_TOKENS.includes(message.trim().charAt(0))) { message = message.trim(); if (message.charAt(0) !== BROADCAST_TOKEN) { message = message.charAt(0) + message; } } message = this.canTalk(message); } // Output the message if (message && message !== true && typeof message.then !== 'function') { if (this.pmTarget) { Chat.sendPM(message, this.user, this.pmTarget); } else { this.room.add(`|c|${this.user.getIdentity(this.room.roomid)}|${message}`); if (this.room && this.room.game && this.room.game.onLogMessage) { this.room.game.onLogMessage(message, this.user); } } } this.update(); return message; } splitCommand(message = this.message, recursing = false): '!' | undefined | ChatHandler { this.cmd = ''; this.cmdToken = ''; this.target = ''; if (!message || !message.trim().length) return; // hardcoded commands if (message.startsWith(`>> `)) { message = `/eval ${message.slice(3)}`; } else if (message.startsWith(`>>> `)) { message = `/evalbattle ${message.slice(4)}`; } else if (message.startsWith(`/me`) && /[^A-Za-z0-9 ]/.test(message.charAt(3))) { message = `/mee ${message.slice(3)}`; } else if (message.startsWith(`/ME`) && /[^A-Za-z0-9 ]/.test(message.charAt(3))) { message = `/MEE ${message.slice(3)}`; } const cmdToken = message.charAt(0); if (!VALID_COMMAND_TOKENS.includes(cmdToken)) return; if (cmdToken === message.charAt(1)) return; if (cmdToken === BROADCAST_TOKEN && /[^A-Za-z0-9]/.test(message.charAt(1))) return; let cmd = ''; let target = ''; const messageSpaceIndex = message.indexOf(' '); if (messageSpaceIndex > 0) { cmd = message.slice(1, messageSpaceIndex).toLowerCase(); target = message.slice(messageSpaceIndex + 1).trim(); } else { cmd = message.slice(1).toLowerCase(); target = ''; } if (cmd.endsWith(',')) cmd = cmd.slice(0, -1); let curCommands: ChatCommands = Chat.commands; let commandHandler; let fullCmd = cmd; do { if (cmd in curCommands) { commandHandler = curCommands[cmd]; } else { commandHandler = undefined; } if (typeof commandHandler === 'string') { // in case someone messed up, don't loop commandHandler = curCommands[commandHandler]; } else if (Array.isArray(commandHandler) && !recursing) { return this.splitCommand(cmdToken + 'help ' + fullCmd.slice(0, -4), true); } if (commandHandler && typeof commandHandler === 'object') { const spaceIndex = target.indexOf(' '); if (spaceIndex > 0) { cmd = target.substr(0, spaceIndex).toLowerCase(); target = target.substr(spaceIndex + 1); } else { cmd = target.toLowerCase(); target = ''; } fullCmd += ' ' + cmd; curCommands = commandHandler as ChatCommands; } } while (commandHandler && typeof commandHandler === 'object'); if (!commandHandler && curCommands.default) { commandHandler = curCommands.default; if (typeof commandHandler === 'string') { commandHandler = curCommands[commandHandler]; } } if (!commandHandler && !recursing) { for (const g in Config.groups) { const groupid = Config.groups[g].id; if (fullCmd === groupid) { return this.splitCommand(`/promote ${target}, ${g}`, true); } else if (fullCmd === 'global' + groupid) { return this.splitCommand(`/globalpromote ${target}, ${g}`, true); } else if (fullCmd === 'de' + groupid || fullCmd === 'un' + groupid || fullCmd === 'globalde' + groupid || fullCmd === 'deglobal' + groupid) { return this.splitCommand(`/demote ${target}`, true); } else if (fullCmd === 'room' + groupid) { return this.splitCommand(`/roompromote ${target}, ${g}`, true); } else if (fullCmd === 'forceroom' + groupid) { return this.splitCommand(`/roompromote !!!${target}, ${g}`, true); } else if (fullCmd === 'roomde' + groupid || fullCmd === 'deroom' + groupid || fullCmd === 'roomun' + groupid) { return this.splitCommand(`/roomdemote ${target}`, true); } } } this.cmd = cmd; this.cmdToken = cmdToken; this.target = target; this.fullCmd = fullCmd; const requireGlobalCommand = ( this.pmTarget || this.room === Rooms.global || (this.room && !(this.user.id in this.room.users)) ); if (typeof commandHandler === 'function' && requireGlobalCommand) { const baseCmd = typeof curCommands[cmd] === 'string' ? curCommands[cmd] : cmd; if (!curCommands['!' + baseCmd]) { return '!'; } } // @ts-ignore type narrowing handled above return commandHandler; } run(commandHandler: string | {call: (...args: any[]) => any}) { // type checked above if (typeof commandHandler === 'string') commandHandler = Chat.commands[commandHandler] as ChatHandler; let result; try { result = commandHandler.call(this, this.target, this.room, this.user, this.connection, this.cmd, this.message); } catch (err) { Monitor.crashlog(err, 'A chat command', { user: this.user.name, room: this.room && this.room.roomid, pmTarget: this.pmTarget && this.pmTarget.name, message: this.message, }); this.sendReply(`|html|
Pokemon Showdown crashed!
Don't worry, we're working on fixing it.
`); } if (result === undefined) result = false; return result; } checkFormat(room: BasicChatRoom | null | undefined, user: User, message: string) { if (!room) return true; if (!room.filterStretching && !room.filterCaps && !room.filterEmojis) return true; if (user.can('bypassall')) return true; if (room.filterStretching && user.name.match(/(.+?)\1{5,}/i)) { return this.errorReply(`Your username contains too much stretching, which this room doesn't allow.`); } if (room.filterCaps && user.name.match(/[A-Z\s]{6,}/)) { return this.errorReply(`Your username contains too many capital letters, which this room doesn't allow.`); } if (room.filterEmojis && user.name.match(emojiRegex)) { return this.errorReply(`Your username contains emojis, which this room doesn't allow.`); } // Removes extra spaces and null characters message = message.trim().replace(/[ \u0000\u200B-\u200F]+/g, ' '); if (room.filterStretching && message.match(/(.+?)\1{7,}/i)) { return this.errorReply(`Your message contains too much stretching, which this room doesn't allow.`); } if (room.filterCaps && message.match(/[A-Z\s]{18,}/)) { return this.errorReply(`Your message contains too many capital letters, which this room doesn't allow.`); } if (room.filterEmojis && message.match(emojiRegex)) { return this.errorReply(`Your message contains emojis, which this room doesn't allow.`); } return true; } checkSlowchat(room: BasicChatRoom | null | undefined, user: User) { if (!room || !room.slowchat) return true; if (user.can('broadcast', null, room)) return true; const lastActiveSeconds = (Date.now() - user.lastMessageTime) / 1000; if (lastActiveSeconds < room.slowchat) return false; return true; } checkBanwords(room: BasicChatRoom | null | undefined, message: string): boolean { if (!room) return true; if (!room.banwordRegex) { if (room.banwords && room.banwords.length) { room.banwordRegex = new RegExp('(?:\\b|(?!\\w))(?:' + room.banwords.join('|') + ')(?:\\b|\\B(?!\\w))', 'i'); } else { room.banwordRegex = true; } } if (!message) return true; if (room.banwordRegex !== true && room.banwordRegex.test(message)) { return false; } return this.checkBanwords(room.parent as ChatRoom, message); } checkGameFilter() { if (!this.room || !this.room.game || !this.room.game.onChatMessage) return false; return this.room.game.onChatMessage(this.message, this.user); } pmTransform(originalMessage: string) { if (!this.pmTarget && this.room !== Rooms.global) throw new Error(`Not a PM`); const targetIdentity = this.pmTarget ? this.pmTarget.getIdentity() : '~'; const prefix = `|pm|${this.user.getIdentity()}|${targetIdentity}|`; return originalMessage.split('\n').map(message => { if (message.startsWith('||')) { return prefix + `/text ` + message.slice(2); } else if (message.startsWith(`|html|`)) { return prefix + `/raw ` + message.slice(6); } else if (message.startsWith(`|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); } return prefix + `/text ` + message; }).join(`\n`); } sendReply(data: string) { if (this.broadcasting && this.broadcastToRoom) { // broadcasting this.add(data); } else { // not broadcasting if (this.pmTarget || this.room === Rooms.global) { data = this.pmTransform(data); this.connection.send(data); } else { this.connection.sendTo(this.room, data); } } } errorReply(message: string) { this.sendReply(`|error|` + message.replace(/\n/g, `\n|error|`)); } addBox(htmlContent: string) { this.add(`|html|
${htmlContent}
`); } sendReplyBox(htmlContent: string) { this.sendReply(`|html|
${htmlContent}
`); } popupReply(message: string) { this.connection.popup(message); } add(data: string) { if (this.pmTarget) { data = this.pmTransform(data); this.user.send(data); if (this.pmTarget !== this.user) this.pmTarget.send(data); return; } this.room.add(data); } send(data: string) { if (this.pmTarget) { data = this.pmTransform(data); this.user.send(data); if (this.pmTarget !== this.user) this.pmTarget.send(data); return; } this.room.send(data); } sendModCommand(data: string) { this.room.sendModsByUser(this.user, data); } privateModCommand() { throw new Error(`this.privateModCommand has been renamed to this.privateModAction, which no longer writes to modlog.`); } privateModAction(msg: string) { this.room.sendMods(msg); this.roomlog(msg); } globalModlog(action: string, user: string | User | null, note: string) { let buf = `(${this.room.roomid}) ${action}: `; if (user) { if (typeof user === 'string') { buf += `[${user}]`; } else { const userid = user.getLastId(); buf += `[${userid}]`; if (user.autoconfirmed && user.autoconfirmed !== userid) buf += ` ac:[${user.autoconfirmed}]`; const alts = user.getAltUsers(false, true).slice(1).map(alt => alt.getLastId()).join('], ['); if (alts.length) buf += ` alts:[${alts}]`; buf += ` [${user.latestIp}]`; } } buf += note.replace(/\n/gm, ' '); Rooms.global.modlog(buf); if (this.room !== Rooms.global) this.room.modlog(buf); } modlog( action: string, user: string | User | null = null, note: string | null = null, options: Partial<{noalts: any, noip: any}> = {} ) { let buf = `(${this.room.roomid}) ${action}: `; if (user) { if (typeof user === 'string') { buf += `[${toID(user)}]`; } else { const userid = user.getLastId(); buf += `[${userid}]`; if (!options.noalts) { if (user.autoconfirmed && user.autoconfirmed !== userid) buf += ` ac:[${user.autoconfirmed}]`; const alts = user.getAltUsers(false, true).slice(1).map(alt => alt.getLastId()).join('], ['); if (alts.length) buf += ` alts:[${alts}]`; } if (!options.noip) buf += ` [${user.latestIp}]`; } } buf += ` by ${this.user.id}`; if (note) buf += `: ${note.replace(/\n/gm, ' ')}`; this.room.modlog(buf); } roomlog(data: string) { if (this.pmTarget) return; this.room.roomlog(data); } logEntry() { throw new Error(`this.logEntry has been renamed to this.roomlog.`); } addModCommand() { throw new Error(`this.addModCommand has been renamed to this.addModAction, which no longer writes to modlog.`); } addModAction(msg: string) { this.room.addByUser(this.user, msg); } update() { if (this.room) this.room.update(); } filter(message: string, targetUser: User | null = null) { if (!this.room || this.room.roomid === 'global') return null; return Chat.filter(this, message, this.user, this.room as GameRoom | ChatRoom, this.connection, targetUser); } statusfilter(status: string) { return Chat.statusfilter(status, this.user); } can(permission: string, target: string | User | null = null, room: BasicChatRoom | null = null) { if (!this.user.can(permission, target, room)) { this.errorReply(this.cmdToken + this.fullCmd + " - Access denied."); return false; } return true; } shouldBroadcast() { return this.cmdToken === BROADCAST_TOKEN; } canBroadcast(ignoreCooldown?: boolean, suppressMessage?: string | null) { if (!this.broadcasting && this.shouldBroadcast()) { if (this.room instanceof Rooms.GlobalRoom) { this.errorReply(`You have no one to broadcast this to.`); this.errorReply(`To see it for yourself, use: /${this.message.substr(1)}`); return false; } if (!this.pmTarget && !this.user.can('broadcast', null, this.room)) { this.errorReply(`You need to be voiced to broadcast this command's information.`); this.errorReply(`To see it for yourself, use: /${this.message.substr(1)}`); return false; } // broadcast cooldown const broadcastMessage = (suppressMessage || this.message).toLowerCase().replace(/[^a-z0-9\s!,]/g, ''); if (!ignoreCooldown && this.room && this.room.lastBroadcast === broadcastMessage && this.room.lastBroadcastTime >= Date.now() - BROADCAST_COOLDOWN && !this.user.can('bypassall')) { this.errorReply("You can't broadcast this because it was just broadcasted."); return false; } const message = this.canTalk(suppressMessage || this.message); if (!message) return false; // canTalk will only return true with no message this.message = message as string; this.broadcastMessage = broadcastMessage; } return true; } runBroadcast(ignoreCooldown = false, suppressMessage: string | null = null) { if (this.broadcasting || !this.shouldBroadcast()) { // Already being broadcast, or the user doesn't intend to broadcast. return true; } if (!this.broadcastMessage) { // Permission hasn't been checked yet. Do it now. if (!this.canBroadcast(ignoreCooldown, suppressMessage)) return false; } this.broadcasting = true; if (this.pmTarget) { this.sendReply('|c~|' + (suppressMessage || this.message)); } else { this.sendReply('|c|' + this.user.getIdentity(this.room.roomid) + '|' + (suppressMessage || this.message)); } if (!ignoreCooldown && !this.pmTarget) { this.room.lastBroadcast = this.broadcastMessage; this.room.lastBroadcastTime = Date.now(); } return true; } canTalk(message: string, room?: GameRoom | ChatRoom | null, targetUser?: User | null): string | null; canTalk(message?: null, room?: GameRoom | ChatRoom | null, targetUser?: User | null): true | null; canTalk(message: string | null = null, room: GameRoom | ChatRoom | null = null, targetUser: User | null = null) { if (!targetUser && this.pmTarget) { targetUser = this.pmTarget; } if (targetUser) { room = null; } else if (!room) { if (this.room.roomid === 'global') { this.connection.popup(`Your message could not be sent:\n\n${message}\n\nIt needs to be sent to a user or room.`); return null; } // @ts-ignore excludes GlobalRoom above room = this.room; } const user = this.user; const connection = this.connection; if (!user.named) { connection.popup(this.tr(`You must choose a name before you can talk.`)); return null; } 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.isHelp) { this.errorReply(this.tr `You are ${lockType} and can't talk in chat. ${lockExpiration}`); this.sendReply(`|html|${this.tr("Get help with this")}`); return null; } if (room.isMuted(user)) { this.errorReply(this.tr(`You are muted and cannot talk in this room.`)); return null; } if (room.modchat && !user.authAtLeast(room.modchat, room)) { if (room.modchat === 'autoconfirmed') { this.errorReply(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.`)); return null; } if (room.modchat === 'trusted') { this.errorReply(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.`)); return null; } const groupName = Config.groups[room.modchat] && Config.groups[room.modchat].name || room.modchat; this.errorReply(this.tr `Because moderated chat is set, you must be of rank ${groupName} or higher to speak in this room.`); return null; } if (!(user.id in room.users)) { connection.popup(`You can't send a message to this room without being in it.`); return null; } } // TODO: translate these messages. Currently there isn't much of a point since languages are room-dependent, and these PM-related messages aren't // attached to any rooms. If we ever get to letting users set their own language these messages should also be translated. - Asheviere if (targetUser) { if (lockType && !targetUser.can('lock')) { this.errorReply(`You are ${lockType} and can only private message members of the global moderation team. ${lockExpiration}`); this.sendReply(`|html|Get help with this`); return null; } if (targetUser.locked && !user.can('lock')) { this.errorReply(`The user "${targetUser.name}" is locked and cannot be PMed.`); return null; } if (Config.pmmodchat && !user.authAtLeast(Config.pmmodchat) && !targetUser.canPromote(user.group, Config.pmmodchat)) { const groupName = Config.groups[Config.pmmodchat] && Config.groups[Config.pmmodchat].name || Config.pmmodchat; this.errorReply(`On this server, you must be of rank ${groupName} or higher to PM users.`); return null; } if (targetUser.blockPMs && (targetUser.blockPMs === true || !user.authAtLeast(targetUser.blockPMs)) && !user.can('lock')) { Chat.maybeNotifyBlocked('pm', targetUser, user); if (!targetUser.can('lock')) { this.errorReply(`This user is blocking private messages right now.`); return null; } else { this.errorReply(`This ${Config.groups[targetUser.group].name} is too busy to answer private messages right now. Please contact a different staff member.`); this.sendReply(`|html|If you need help, try opening a help ticket`); return null; } } if (user.blockPMs && (user.blockPMs === true || !targetUser.authAtLeast(user.blockPMs)) && !targetUser.can('lock')) { this.errorReply(`You are blocking private messages right now.`); return null; } } } if (typeof message !== 'string') return true; if (!message) { connection.popup(this.tr("Your message can't be blank.")); return null; } let length = message.length; length += 10 * message.replace(/[^\ufdfd]*/g, '').length; if (length > MAX_MESSAGE_LENGTH && !user.can('ignorelimits')) { this.errorReply(this.tr("Your message is too long: ") + message); return null; } // remove zalgo // tslint:disable-next-line: max-line-length message = message.replace(/[\u0300-\u036f\u0483-\u0489\u0610-\u0615\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06ED\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]{3,}/g, ''); if (/[\u115f\u1160\u239b-\u23b9]/.test(message)) { this.errorReply(this.tr("Your message contains banned characters.")); return null; } // If the corresponding config option is set, non-AC users cannot send links, except to staff. if (Config.restrictLinks && !user.autoconfirmed) { const links = message.match(Chat.linkRegex); const allLinksWhitelisted = !links || links.every(link => { link = link.toLowerCase(); const domainMatches = /^(?:http:\/\/|https:\/\/)?(?:[^/]*\.)?([^/.]*\.[^/.]*)\.?($|\/|:)/.exec(link); const domain = domainMatches && domainMatches[1]; const hostMatches = /^(?:http:\/\/|https:\/\/)?([^/]*[^/.])\.?($|\/|:)/.exec(link); let host = hostMatches && hostMatches[1]; if (host && host.startsWith('www.')) host = host.slice(4); if (!domain || !host) return null; return LINK_WHITELIST.includes(host) || LINK_WHITELIST.includes(`*.${domain}`); }); if (!allLinksWhitelisted && !(targetUser && targetUser.can('lock') || (room && room.isHelp))) { this.errorReply("Your account must be autoconfirmed to send links to other users, except for global staff."); return null; } } if (!this.checkFormat(room, user, message)) { return null; } if (!this.checkSlowchat(room, user)) { // @ts-ignore ~ The truthiness of room and room.slowchat are evaluated in checkSlowchat this.errorReply(this.tr `This room has slow-chat enabled. You can only talk once every ${room.slowchat} seconds.`); return null; } if (!this.checkBanwords(room, user.name) && !user.can('bypassall')) { this.errorReply(this.tr(`Your username contains a phrase banned by this room.`)); return null; } if (user.userMessage && (!this.checkBanwords(room, user.userMessage) && !user.can('bypassall'))) { this.errorReply(this.tr(`Your status message contains a phrase banned by this room.`)); return null; } if (!this.checkBanwords(room, message) && !user.can('mute', null, room)) { this.errorReply(this.tr("Your message contained banned words in this room.")); return null; } const gameFilter = this.checkGameFilter(); if (gameFilter && !user.can('bypassall')) { this.errorReply(gameFilter); return null; } if (room) { const normalized = message.trim(); if (!user.can('bypassall') && (['help', 'lobby'].includes(room.roomid)) && (normalized === user.lastMessage) && ((Date.now() - user.lastMessageTime) < MESSAGE_COOLDOWN)) { this.errorReply(this.tr("You can't send the same message again so soon.")); return null; } user.lastMessage = message; user.lastMessageTime = Date.now(); } if (room && room.highTraffic && toID(message).replace(/[^a-z]+/, '').length < 2 && !user.can('broadcast', null, room)) { this.errorReply( this.tr('Due to this room being a high traffic room, your message must contain at least two letters.') ); return null; } if (Chat.filters.length) { return Chat.filter(this, message, user, room, connection, targetUser); } return message; } canEmbedURI(uri: string, isRelative = false) { if (uri.startsWith('https://')) return uri; if (uri.startsWith('//')) return uri; if (uri.startsWith('data:')) return uri; if (!uri.startsWith('http://')) { if (/^[a-z]+:\/\//.test(uri) || isRelative) { this.errorReply("URIs must begin with 'https://' or 'http://' or 'data:'"); return null; } } else { uri = uri.slice(7); } const slashIndex = uri.indexOf('/'); let domain = (slashIndex >= 0 ? uri.slice(0, slashIndex) : uri); // heuristic that works for all the domains we care about const secondLastDotIndex = domain.lastIndexOf('.', domain.length - 5); if (secondLastDotIndex >= 0) domain = domain.slice(secondLastDotIndex + 1); const approvedDomains = { 'imgur.com': 1, 'gyazo.com': 1, 'puu.sh': 1, 'rotmgtool.com': 1, 'pokemonshowdown.com': 1, 'nocookie.net': 1, 'blogspot.com': 1, 'imageshack.us': 1, 'deviantart.net': 1, 'd.pr': 1, 'pokefans.net': 1, }; if (domain in approvedDomains) { return '//' + uri; } if (domain === 'bit.ly') { this.errorReply("Please don't use URL shorteners."); return null; } // unknown URI, allow HTTP to be safe return 'http://' + uri; } canHTML(htmlContent: string | null) { htmlContent = ('' + (htmlContent || '')).trim(); if (!htmlContent) return ''; const images = /]*/ig; let match; // tslint:disable-next-line: no-conditional-assignment tslint doesn't support allowing ((assignment)) while ((match = images.exec(htmlContent))) { if (this.room.isPersonal && !this.user.can('announce')) { this.errorReply("Images are not allowed in personal rooms."); return null; } if (!/width=([0-9]+|"[0-9]+")/i.test(match[0]) || !/height=([0-9]+|"[0-9]+")/i.test(match[0])) { // Width and height are required because most browsers insert the // element before width and height are known, and when the // image is loaded, this changes the height of the chat area, which // messes up autoscrolling. this.errorReply('All images must have a width and height attribute'); return null; } const srcMatch = /src\s*=\s*"?([^ "]+)(\s*")?/i.exec(match[0]); if (srcMatch) { const uri = this.canEmbedURI(srcMatch[1], true); if (!uri) return null; htmlContent = `${htmlContent.slice(0, match.index + srcMatch.index)}src="${uri}"${htmlContent.slice(match.index + srcMatch.index + srcMatch[0].length)}`; // lastIndex is inaccurate since html was changed images.lastIndex = match.index + 11; } } if ((this.room.isPersonal || this.room.isPrivate === true) && !this.user.can('lock') && htmlContent.replace(/\s*style\s*=\s*"?[^"]*"\s*>/g, '>').match(/]/)) { this.errorReply('You do not have permission to use scripted buttons in HTML.'); this.errorReply('If you just want to link to a room, you can do this: '); return null; } if (/>here.? without an opening tag."); return null; } if (tag.substr(2) !== stack.pop()) { this.errorReply("Missing or it's in the wrong place."); return null; } } else { stack.push(tag.substr(1)); } } if (stack.length) { this.errorReply("Missing ."); return null; } } return htmlContent; } targetUserOrSelf(target: string, exactName: boolean) { if (!target) { this.targetUsername = this.user.name; this.inputUsername = this.user.name; return this.user; } this.splitTarget(target, exactName); return this.targetUser; } /** * Given a message in the form "USERNAME" or "USERNAME, MORE", splits * it apart: * * - `this.targetUser` will be the User corresponding to USERNAME * (or null, if not found) * * - `this.inputUsername` will be the text of USERNAME, unmodified * * - `this.targetUsername` will be the username, if found, or * this.inputUsername otherwise * * - and the text of MORE will be returned (empty string, if the * message has no comma) * */ splitTarget(target: string, exactName = false) { const [name, rest] = this.splitOne(target); this.targetUser = Users.get(name, exactName); this.inputUsername = name.trim(); this.targetUsername = this.targetUser ? this.targetUser.name : this.inputUsername; return rest; } } export const Chat = new class { constructor() { void this.loadTranslations(); } readonly multiLinePattern = new PatternTester(); /********************************************************* * Load command files *********************************************************/ baseCommands: ChatCommands = undefined!; commands: ChatCommands = undefined!; basePages: PageTable = undefined!; pages: PageTable = undefined!; readonly destroyHandlers: (() => void)[] = []; roomSettings: SettingsHandler[] = []; /********************************************************* * Load chat filters *********************************************************/ readonly filters: ChatFilter[] = []; filter( context: CommandContext, message: string, user: User, room: GameRoom | ChatRoom | null, connection: Connection, targetUser: User | null = null ) { // Chat filters can choose to: // 1. return false OR null - to not send a user's message // 2. return an altered string - to alter a user's message // 3. return undefined to send the original message through const originalMessage = message; for (const curFilter of Chat.filters) { const output = curFilter.call(context, message, user, room, connection, targetUser, originalMessage); if (output === false) return null; if (!output && output !== undefined) return output; if (output !== undefined) message = output; } return message; } readonly namefilters: NameFilter[] = []; namefilter(name: string, user: User) { if (!Config.disablebasicnamefilter) { // whitelist // \u00A1-\u00BF\u00D7\u00F7 Latin punctuation/symbols // \u02B9-\u0362 basic combining accents // \u2012-\u2027\u2030-\u205E Latin punctuation/symbols extended // \u2050-\u205F fractions extended // \u2190-\u23FA\u2500-\u2BD1 misc symbols // \u2E80-\u32FF CJK symbols // \u3400-\u9FFF CJK // \uF900-\uFAFF\uFE00-\uFE6F CJK extended // tslint:disable-next-line: max-line-length name = name.replace(/[^a-zA-Z0-9 /\\.~()<>^*%&=+$#_'?!"\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2190-\u23FA\u2500-\u2BD1\u2E80-\u32FF\u3400-\u9FFF\uF900-\uFAFF\uFE00-\uFE6F-]+/g, ''); // blacklist // \u00a1 upside-down exclamation mark (i) // \u2580-\u2590 black bars // \u25A0\u25Ac\u25AE\u25B0 black bars // \u534d\u5350 swastika // \u2a0d crossed integral (f) name = name.replace(/[\u00a1\u2580-\u2590\u25A0\u25Ac\u25AE\u25B0\u2a0d\u534d\u5350]/g, ''); // e-mail address if (name.includes('@') && name.includes('.')) return ''; // url // tslint:disable-next-line: max-line-length if (/[a-z0-9]\.(com|net|org|us|uk|co|gg|tk|ml|gq|ga|xxx|download|stream)\b/i.test(name)) name = name.replace(/\./g, ''); // Limit the amount of symbols allowed in usernames to 4 maximum, and disallow (R) and (C) from being used in the middle of names. // tslint:disable-next-line: max-line-length const nameSymbols = name.replace(/[^\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2090-\u23FA\u2500-\u2BD1]+/g, ''); // \u00ae\u00a9 (R) (C) // tslint:disable-next-line: max-line-length if (nameSymbols.length > 4 || /[^a-z0-9][a-z0-9][^a-z0-9]/.test(name.toLowerCase() + ' ') || /[\u00ae\u00a9].*[a-zA-Z0-9]/.test(name)) name = name.replace(/[\u00A1-\u00BF\u00D7\u00F7\u02B9-\u0362\u2012-\u2027\u2030-\u205E\u2050-\u205F\u2190-\u23FA\u2500-\u2BD1\u2E80-\u32FF\u3400-\u9FFF\uF900-\uFAFF\uFE00-\uFE6F]+/g, '').replace(/[^A-Za-z0-9]{2,}/g, ' ').trim(); } name = name.replace(/^[^A-Za-z0-9]+/, ""); // remove symbols from start name = name.replace(/@/g, ""); // Remove @ as this is used to indicate status messages // cut name length down to 18 chars if (/[A-Za-z0-9]/.test(name.slice(18))) { name = name.replace(/[^A-Za-z0-9]+/g, ""); } else { name = name.slice(0, 18); } name = Dex.getName(name); for (const curFilter of Chat.namefilters) { name = curFilter(name, user); if (!name) return ''; } return name; } readonly hostfilters: HostFilter[] = []; hostfilter(host: string, user: User, connection: Connection, hostType: string) { for (const curFilter of Chat.hostfilters) { curFilter(host, user, connection, hostType); } } readonly loginfilters: LoginFilter[] = []; loginfilter(user: User, oldUser: User | null, usertype: string) { for (const curFilter of Chat.loginfilters) { curFilter(user, oldUser, usertype); } } readonly nicknamefilters: NameFilter[] = []; nicknamefilter(nickname: string, user: User) { for (const curFilter of Chat.nicknamefilters) { nickname = curFilter(nickname, user); if (!nickname) return ''; } return nickname; } readonly statusfilters: StatusFilter[] = []; statusfilter(status: string, user: User) { status = status.replace(/\|/g, ''); for (const curFilter of Chat.statusfilters) { status = curFilter(status, user); if (!status) return ''; } return status; } /********************************************************* * Translations *********************************************************/ /** language id -> language name */ readonly languages = new Map(); /** language id -> (english string -> translated string) */ readonly translations = new Map>(); loadTranslations() { return FS(TRANSLATION_DIRECTORY).readdir().then(files => { // ensure that english is the first entry when we iterate over Chat.languages Chat.languages.set('english', 'English'); for (const fname of files) { if (!fname.endsWith('.json')) continue; interface TRStrings { [k: string]: string; } const content: {name: string, strings: TRStrings} = require(`../${TRANSLATION_DIRECTORY}${fname}`); const id = fname.slice(0, -5); Chat.languages.set(id, content.name || "Unknown Language"); Chat.translations.set(id, new Map()); if (content.strings) { for (const key in content.strings) { const keyLabels: string[] = []; const valLabels: string[] = []; const newKey = key.replace(/\${.+?}/g, str => { keyLabels.push(str); return '${}'; }).replace(/\[TN: ?.+?\]/g, ''); const val = content.strings[key].replace(/\${.+?}/g, (str: string) => { valLabels.push(str); return '${}'; }).replace(/\[TN: ?.+?\]/g, ''); Chat.translations.get(id)!.set(newKey, [val, keyLabels, valLabels]); } } } }); } tr(language: string | null): (fStrings: TemplateStringsArray | string, ...fKeys: any) => string; tr(language: string | null, strings: TemplateStringsArray | string, ...keys: any[]): string; tr(language: string | null, strings: TemplateStringsArray | string = '', ...keys: any[]) { if (!language) language = 'english'; language = toID(language); if (!Chat.translations.has(language)) throw new Error(`Trying to translate to a nonexistent language: ${language}`); if (!strings.length) { return ((fStrings: TemplateStringsArray | string, ...fKeys: any) => { return Chat.tr(language, fStrings, ...fKeys); }); } // If strings is an array (normally the case), combine before translating. const trString = Array.isArray(strings) ? strings.join('${}') : strings as string; const entry = Chat.translations.get(language)!.get(trString); let [translated, keyLabels, valLabels] = entry || ["", [], []]; if (!translated) translated = trString; // Replace the gaps in the template string if (keys.length) { let reconstructed = ''; const left: (string | null)[] = keyLabels.slice(); for (const [i, str] of translated.split('${}').entries()) { reconstructed += str; if (keys[i]) { let index = left.indexOf(valLabels[i]); if (index < 0) { index = left.findIndex(val => !!val); } if (index < 0) index = i; reconstructed += keys[index]; left[index] = null; } } translated = reconstructed; } return translated; } readonly MessageContext = MessageContext; readonly CommandContext = CommandContext; readonly PageContext = PageContext; /** * Command parser * * Usage: * Chat.parse(message, room, user, connection) * * Parses the message. If it's a command, the command is executed, if * not, it's displayed directly in the room. * * Examples: * Chat.parse("/join lobby", room, user, connection) * will make the user join the lobby. * * Chat.parse("Hi, guys!", room, user, connection) * will return "Hi, guys!" if the user isn't muted, or * if he's muted, will warn him that he's muted. * * The return value is the return value of the command handler, if any, * or the message, if there wasn't a command. This value could be a success * or failure (few commands report these) or a Promise for when the command * is done executing, if it's not currently done. * * @param message - the message the user is trying to say * @param room - the room the user is trying to say it in * @param user - the user that sent the message * @param connection - the connection the user sent the message from */ parse(message: string, room: Room, user: User, connection: Connection) { Chat.loadPlugins(); const context = new CommandContext({message, room, user, connection}); return context.parse(); } sendPM(message: string, user: User, pmTarget: User, onlyRecipient: User | null = null) { const buf = `|pm|${user.getIdentity()}|${pmTarget.getIdentity()}|${message}`; if (onlyRecipient) return onlyRecipient.send(buf); user.send(buf); if (pmTarget !== user) pmTarget.send(buf); pmTarget.lastPM = user.id; user.lastPM = pmTarget.id; } packageData: AnyObject = {}; uncacheTree(root: string) { let toUncache = [require.resolve('../' + root)]; do { const newuncache: string[] = []; for (const target of toUncache) { if (require.cache[target]) { // cachedModule const children: {id: string}[] = require.cache[target].children; newuncache.push( ...(children .filter(cachedModule => !cachedModule.id.endsWith('.node')) .map(cachedModule => cachedModule.id)) ); delete require.cache[target]; } } toUncache = newuncache; } while (toUncache.length > 0); } uncacheDir(root: string) { const absoluteRoot = FS(root).path; for (const key in require.cache) { if (key.startsWith(absoluteRoot)) { delete require.cache[key]; } } } uncache(path: string) { const absolutePath = require.resolve('../' + path); delete require.cache[absolutePath]; } loadPlugin(file: string) { let plugin; if (file.endsWith('.ts')) { plugin = require(`./${file.slice(0, -3)}`); } else if (file.endsWith('.js')) { // Switch to server/ because we'll be in .server-dist/ after this file is compiled plugin = require(`../server/${file}`); } else { return; } this.loadPluginData(plugin); } loadPluginData(plugin: AnyObject) { if (plugin.commands) Object.assign(Chat.commands, plugin.commands); if (plugin.pages) Object.assign(Chat.pages, plugin.pages); if (plugin.destroy) Chat.destroyHandlers.push(plugin.destroy); 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.nicknamefilter) Chat.nicknamefilters.push(plugin.nicknamefilter); if (plugin.statusfilter) Chat.statusfilters.push(plugin.statusfilter); } loadPlugins() { if (Chat.commands) return; void FS('package.json').readIfExists().then(data => { if (data) Chat.packageData = JSON.parse(data); }); // Install plug-in commands and chat filters // All resulting filenames will be relative to basePath const getFiles = (basePath: string, path: string): string[] => { const filesInThisDir = FS(`${basePath}/${path}`).readdirSync(); let allFiles: string[] = []; for (const file of filesInThisDir) { const fileWithPath = path + (path ? '/' : '') + file; if (FS(`${basePath}/${fileWithPath}`).isDirectorySync()) { if (file.startsWith('.')) continue; allFiles = allFiles.concat(getFiles(basePath, fileWithPath)); } else { allFiles.push(fileWithPath); } } return allFiles; }; Chat.commands = Object.create(null); Chat.pages = Object.create(null); const coreFiles = FS('server/chat-commands').readdirSync(); for (const file of coreFiles) { this.loadPlugin(`chat-commands/${file}`); } Chat.baseCommands = Chat.commands; Chat.basePages = Chat.pages; Chat.commands = Object.assign(Object.create(null), Chat.baseCommands); Chat.pages = Object.assign(Object.create(null), Chat.basePages); // Load filters from Config this.loadPluginData(Config); this.loadPluginData(Tournaments); let files = FS('server/chat-plugins').readdirSync(); try { if (FS('server/chat-plugins/private').isDirectorySync()) { files = files.concat(getFiles('server/chat-plugins', 'private')); } } catch (err) { if (err.code !== 'ENOENT') throw err; } for (const file of files) { this.loadPlugin(`chat-plugins/${file}`); } } destroy() { for (const handler of Chat.destroyHandlers) { handler(); } } /** * Escapes HTML in a string. */ escapeHTML(str: string) { if (!str) return ''; return ('' + str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'); } /** * Strips HTML from a string. */ stripHTML(htmlContent: string) { if (!htmlContent) return ''; return htmlContent.replace(/<[^>]*>/g, ''); } /** * Template string tag function for escaping HTML */ html(strings: TemplateStringsArray, ...args: any) { let buf = strings[0]; let i = 0; while (i < args.length) { buf += this.escapeHTML(args[i]); buf += strings[++i]; } return buf; } /** * Returns singular (defaulting to '') if num is 1, or plural * (defaulting to 's') otherwise. Helper function for pluralizing * words. */ plural(num: any, pluralSuffix = 's', singular = '') { if (num && typeof num.length === 'number') { num = num.length; } else if (num && typeof num.size === 'number') { num = num.size; } else { num = Number(num); } return (num !== 1 ? pluralSuffix : singular); } /** * Counts the thing passed. * * Chat.count(2, "days") === "2 days" * Chat.count(1, "days") === "1 day" * Chat.count(["foo"], "things are") === "1 thing is" * */ count(num: any, pluralSuffix: string, singular = "") { if (num && typeof num.length === 'number') { num = num.length; } else if (num && typeof num.size === 'number') { num = num.size; } else { num = Number(num); } if (!singular) { if (pluralSuffix.endsWith("s")) { singular = pluralSuffix.slice(0, -1); } else if (pluralSuffix.endsWith("s have")) { singular = pluralSuffix.slice(0, -6) + " has"; } else if (pluralSuffix.endsWith("s were")) { singular = pluralSuffix.slice(0, -6) + " was"; } } const space = singular.startsWith('<') ? '' : ' '; return `${num}${space}${num > 1 ? pluralSuffix : singular}`; } /** * Like string.split(delimiter), but only recognizes the first `limit` * delimiters (default 1). * * `"1 2 3 4".split(" ", 2) => ["1", "2"]` * * `Chat.splitFirst("1 2 3 4", " ", 1) => ["1", "2 3 4"]` * * Returns an array of length exactly limit + 1. * */ splitFirst(str: string, delimiter: string, limit = 1) { const splitStr: string[] = []; while (splitStr.length < limit) { const delimiterIndex = str.indexOf(delimiter); if (delimiterIndex >= 0) { splitStr.push(str.slice(0, delimiterIndex)); str = str.slice(delimiterIndex + delimiter.length); } else { splitStr.push(str); str = ''; } } splitStr.push(str); return splitStr; } /** * Returns a timestamp in the form {yyyy}-{MM}-{dd} {hh}:{mm}:{ss}. * * options.human = true will reports hours human-readable */ toTimestamp(date: Date, options: {human?: any} = {}) { const human = options.human; let parts: any[] = [ date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds(), ]; if (human) { parts.push(parts[3] >= 12 ? 'pm' : 'am'); parts[3] = parts[3] % 12 || 12; } parts = parts.map(val => val < 10 ? '0' + val : '' + val); return parts.slice(0, 3).join("-") + " " + parts.slice(3, human ? 5 : 6).join(":") + (human ? "" + parts[6] : ""); } /** * Takes a number of milliseconds, and reports the duration in English: hours, minutes, etc. * * options.hhmmss = true will instead report the duration in 00:00:00 format * */ toDurationString(val: number, options: {hhmmss?: any, precision?: number} = {}) { // TODO: replace by Intl.DurationFormat or equivalent when it becomes available (ECMA-402) // https://github.com/tc39/ecma402/issues/47 const date = new Date(+val); 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); const precision = (options && options.precision ? options.precision : parts.length); if (options && options.hhmmss) { const str = parts.slice(positiveIndex).map(value => value < 10 ? "0" + value : "" + value).join(":"); return str.length === 2 ? "00:" + str : str; } // round least significant displayed unit if (positiveIndex + precision < parts.length && precision > 0 && positiveIndex >= 0) { if (parts[positiveIndex + precision] >= roundingBoundaries[positiveIndex + precision - 1]) { parts[positiveIndex + precision - 1]++; } } return parts .slice(positiveIndex) .reverse() .map((value, index) => value ? value + " " + unitNames[index] + (value > 1 ? "s" : "") : "") .reverse() .slice(0, precision) .join(" ") .trim(); } /** * Takes an array and turns it into a sentence string by adding commas and the word "and" */ toListString(arr: string[]) { if (!arr.length) return ''; if (arr.length === 1) return arr[0]; if (arr.length === 2) return `${arr[0]} and ${arr[1]}`; return `${arr.slice(0, -1).join(", ")}, and ${arr.slice(-1)[0]}`; } /** * Takes an array and turns it into a sentence string by adding commas and the word "or" */ toOrList(arr: string[]) { if (!arr.length) return ''; if (arr.length === 1) return arr[0]; if (arr.length === 2) return `${arr[0]} or ${arr[1]}`; return `${arr.slice(0, -1).join(", ")}, or ${arr.slice(-1)[0]}`; } collapseLineBreaksHTML(htmlContent: string) { htmlContent = htmlContent.replace(/<[^>]*>/g, tag => tag.replace(/\n/g, ' ')); htmlContent = htmlContent.replace(/\n/g, ' '); return htmlContent; } getDataPokemonHTML(template: Template, gen = 7, tier = '') { if (typeof template === 'string') template = Object.assign({}, Dex.getTemplate(template)); let buf = '
  • '; buf += '' + (tier || template.tier) + ' '; buf += ` `; buf += `${template.species} `; buf += ''; if (template.types) { for (const type of template.types) { buf += `${type}`; } } buf += ' '; if (gen >= 3) { buf += ''; if (template.abilities['1'] && (gen >= 4 || Dex.getAbility(template.abilities['1']).gen === 3)) { buf += '' + template.abilities['0'] + '
    ' + template.abilities['1'] + '
    '; } else { buf += '' + template.abilities['0'] + ''; } if (template.abilities['H'] && template.abilities['S']) { buf += '' + template.abilities['H'] + '
    (' + template.abilities['S'] + ')
    '; } else if (template.abilities['H']) { buf += '' + template.abilities['H'] + ''; } else if (template.abilities['S']) { // special case for Zygarde buf += '(' + template.abilities['S'] + ')'; } else { buf += ''; } buf += '
    '; } let bst = 0; for (const baseStat of Object.values(template.baseStats)) { bst += baseStat; } buf += ''; buf += 'HP
    ' + template.baseStats.hp + '
    '; buf += 'Atk
    ' + template.baseStats.atk + '
    '; buf += 'Def
    ' + template.baseStats.def + '
    '; if (gen <= 1) { bst -= template.baseStats.spd; buf += 'Spc
    ' + template.baseStats.spa + '
    '; } else { buf += 'SpA
    ' + template.baseStats.spa + '
    '; buf += 'SpD
    ' + template.baseStats.spd + '
    '; } buf += 'Spe
    ' + template.baseStats.spe + '
    '; buf += 'BST
    ' + bst + '
    '; buf += '
    '; buf += '
  • '; return `
      ${buf}
    `; } getDataMoveHTML(move: Move) { if (typeof move === 'string') move = Object.assign({}, Dex.getMove(move)); let buf = `
    • `; buf += `${move.name} `; // encoding is important for the ??? type icon const encodedMoveType = encodeURIComponent(move.type); buf += `${move.type}`; buf += `${move.category} `; // tslint:disable-next-line: max-line-length if (move.basePower) buf += `Power
      ${typeof move.basePower === 'number' ? move.basePower : '—'}
      `; buf += `Accuracy
      ${typeof move.accuracy === 'number' ? (move.accuracy + '%') : '—'}
      `; const basePP = move.pp || 1; const pp = Math.floor(move.noPPBoosts ? basePP : basePP * 8 / 5); buf += `PP
      ${pp}
      `; buf += `${move.shortDesc || move.desc} `; buf += `
    `; return buf; } getDataAbilityHTML(ability: Ability) { if (typeof ability === 'string') ability = Object.assign({}, Dex.getAbility(ability)); let buf = `
    • `; buf += `${ability.name} `; buf += `${ability.shortDesc || ability.desc} `; buf += `
    `; return buf; } getDataItemHTML(item: string | Item) { if (typeof item === 'string') item = Object.assign({}, Dex.getItem(item)); let buf = `
    • `; buf += ` ${item.name} `; buf += `${item.shortDesc || item.desc} `; buf += `
    `; return buf; } /** * Visualizes eval output in a slightly more readable form */ stringify(value: any, depth = 0): string { if (value === undefined) return `undefined`; if (value === null) return `null`; if (typeof value === 'number' || typeof value === 'boolean') { return `${value}`; } if (typeof value === 'string') { return `"${value}"`; // NOT ESCAPED } if (typeof value === 'symbol') { return value.toString(); } if (Array.isArray(value)) { if (depth > 10) return `[array]`; return `[` + value.map(elem => Chat.stringify(elem, depth + 1)).join(`, `) + `]`; } if (value instanceof RegExp || value instanceof Date || value instanceof Function) { if (depth && value instanceof Function) return `Function`; return `${value}`; } let constructor = ''; if (value.constructor && value.constructor.name && typeof value.constructor.name === 'string') { constructor = value.constructor.name; if (constructor === 'Object') constructor = ''; } else { constructor = 'null'; } if (value.toString) { try { const stringValue = value.toString(); if (typeof stringValue === 'string' && stringValue !== '[object Object]' && stringValue !== `[object ${constructor}]`) { return `${constructor}(${stringValue})`; } } catch (e) {} } let buf = ''; for (const key in value) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; if (depth > 2 || (depth && constructor)) { buf = '...'; break; } if (buf) buf += `, `; let displayedKey = key; if (!/^[A-Za-z0-9_$]+$/.test(key)) displayedKey = JSON.stringify(key); buf += `${displayedKey}: ` + Chat.stringify(value[key], depth + 1); } if (constructor && !buf && constructor !== 'null') return constructor; return `${constructor}{${buf}}`; } /** * Gets the dimension of the image at url. Returns 0x0 if the image isn't found, as well as the relevant error. */ getImageDimensions(url: string): Promise<{height: number, width: number, err?: Error}> { return new Promise(resolve => { probe(url).then(dimensions => resolve(dimensions), (err: Error) => resolve({height: 0, width: 0, err})); }); } /** * Generates dimensions to fit an image at url into a maximum size of maxWidth x maxHeight, * preserving aspect ratio. */ async fitImage(url: string, maxHeight = 300, maxWidth = 300) { const {height, width} = await Chat.getImageDimensions(url); if (width <= maxWidth && height <= maxHeight) return [width, height]; let ratio; if (height * (maxWidth / maxHeight) > width) { ratio = maxHeight / height; } else { ratio = maxWidth / width; } return [Math.round(width * ratio), Math.round(height * ratio)]; } /** * Notifies a targetUser that a user was blocked from reaching them due to a setting they have enabled. */ maybeNotifyBlocked(blocked: 'pm' | 'challenge', targetUser: User, user: User) { const prefix = `|pm|~|${targetUser.getIdentity()}|/nonotify `; const options = 'or change it in the menu in the upper right.'; if (blocked === 'pm') { if (!targetUser.blockPMsNotified) { targetUser.send(`${prefix}The user '${user.name}' attempted to PM you but was blocked. To enable PMs, use /unblockpms ${options}`); targetUser.blockPMsNotified = true; } } else if (blocked === 'challenge') { if (!targetUser.blockChallengesNotified) { targetUser.send(`${prefix}The user '${user.name}' attempted to challenge you to a battle but was blocked. To enable challenges, use /unblockchallenges ${options}`); targetUser.blockChallengesNotified = true; } } } readonly formatText = formatText; readonly linkRegex = linkRegex; readonly stripFormatting = stripFormatting; readonly filterWords: {[k: string]: FilterWord[]} = {}; readonly monitors: {[k: string]: Monitor} = {}; readonly namefilterwhitelist = new Map(); /** * Inappropriate userid : number of times the name has been forcerenamed */ readonly forceRenames = new Map(); registerMonitor(id: string, entry: Monitor) { if (!Chat.filterWords[id]) Chat.filterWords[id] = []; Chat.monitors[id] = entry; } resolvePage(pageid: string, user: User, connection: Connection) { return (new PageContext({pageid, user, connection})).resolve(); } }; /** * Used by ChatMonitor. */ export type FilterWord = [RegExp, string, string, string | null, number]; export type MonitorHandler = ( this: CommandContext, line: FilterWord, room: ChatRoom | GameRoom | null, user: User, message: string, lcMessage: string, isStaff: boolean ) => string | false | undefined; export interface Monitor { location: string; punishment: string; label: string; condition?: string; monitor?: MonitorHandler; }