/**
* Command parser
* Pokemon Showdown - http://pokemonshowdown.com/
*
* This is the command parser. Call it with CommandParser.parse
* (scroll down to its definition for details)
*
* Individual commands are put in:
* commands.js - "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 license
*/
/*
To reload chat commands:
/hotpatch chat
*/
'use strict';
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 fs = require('fs');
const path = require('path');
/*********************************************************
* Load command files
*********************************************************/
let baseCommands = exports.baseCommands = require('./commands.js').commands;
let commands = exports.commands = Object.clone(baseCommands);
// Install plug-in commands
// info always goes first so other plugins can shadow it
Object.merge(commands, require('./chat-plugins/info.js').commands);
fs.readdirSync(path.resolve(__dirname, 'chat-plugins')).forEach(function (file) {
if (file.substr(-3) !== '.js' || file === 'info.js') return;
Object.merge(commands, require('./chat-plugins/' + file).commands);
});
/*********************************************************
* Modlog
*********************************************************/
let modlog = exports.modlog = {
lobby: fs.createWriteStream(path.resolve(__dirname, 'logs/modlog/modlog_lobby.txt'), {flags:'a+'}),
battle: fs.createWriteStream(path.resolve(__dirname, 'logs/modlog/modlog_battle.txt'), {flags:'a+'}),
};
let writeModlog = exports.writeModlog = function (roomid, text) {
if (!modlog[roomid]) {
modlog[roomid] = fs.createWriteStream(path.resolve(__dirname, 'logs/modlog/modlog_' + roomid + '.txt'), {flags:'a+'});
}
modlog[roomid].write('[' + (new Date().toJSON()) + '] ' + text + '\n');
};
/*********************************************************
* Parser
*********************************************************/
/**
* Can this user talk?
* Shows an error message if not.
*/
function canTalk(user, room, connection, message, targetUser) {
if (!user.named) {
connection.popup("You must choose a name before you can talk.");
return false;
}
if (!user.can('bypassall')) {
if (room && user.locked) {
this.errorReply("You are locked from talking in chat.");
return false;
}
if (room && room.isMuted(user)) {
this.errorReply("You are muted and cannot talk in this room.");
return false;
}
if (room && room.modchat) {
let userGroup = user.group;
if (room.auth) {
if (room.auth[user.userid]) {
userGroup = room.auth[user.userid];
} else if (room.isPrivate === true) {
userGroup = ' ';
}
}
if (room.modchat === 'autoconfirmed') {
if (!user.autoconfirmed && userGroup === ' ') {
this.errorReply("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 false;
}
} else if (Config.groupsranking.indexOf(userGroup) < Config.groupsranking.indexOf(room.modchat) && !user.can('makeroom')) {
let groupName = Config.groups[room.modchat].name || room.modchat;
this.errorReply("Because moderated chat is set, you must be of rank " + groupName + " or higher to speak in this room.");
return false;
}
}
if (room && !(user.userid in room.users)) {
connection.popup("You can't send a message to this room without being in it.");
return false;
}
}
if (typeof message === 'string') {
if (!message) {
connection.popup("Your message can't be blank.");
return false;
}
if (message.length > MAX_MESSAGE_LENGTH && !user.can('ignorelimits')) {
this.errorReply("Your message is too long: " + message);
return false;
}
// remove zalgo
message = message.replace(/[\u0300-\u036f\u0483-\u0489\u064b-\u065f\u0670\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]{3,}/g, '');
if (/[\u239b-\u23b9]/.test(message)) {
this.errorReply("Your message contains banned characters.");
return false;
}
if (room && room.id === 'lobby') {
let normalized = message.trim();
if ((normalized === user.lastMessage) &&
((Date.now() - user.lastMessageTime) < MESSAGE_COOLDOWN)) {
this.errorReply("You can't send the same message again so soon.");
return false;
}
user.lastMessage = message;
user.lastMessageTime = Date.now();
}
if (Config.chatfilter) {
/*jshint validthis:true */
return Config.chatfilter.call(this, message, user, room, connection, targetUser);
}
return message;
}
return true;
}
let Context = exports.Context = (function () {
function Context(options) {
this.cmd = options.cmd || '';
this.cmdToken = options.cmdToken || '';
this.target = options.target || '';
this.message = options.message || '';
this.levelsDeep = options.levelsDeep || 0;
this.namespaces = options.namespaces || null;
this.room = options.room || null;
this.user = options.user || null;
this.connection = options.connection || null;
this.targetUserName = '';
this.targetUser = null;
}
Context.prototype.sendReply = function (data) {
if (this.broadcasting) {
this.room.add(data);
} else {
this.connection.sendTo(this.room, data);
}
};
Context.prototype.errorReply = function (message) {
if (this.pmTarget) {
this.connection.send('|pm|' + this.user.getIdentity() + '|' + (this.pmTarget.getIdentity ? this.pmTarget.getIdentity() : ' ' + this.pmTarget) + '|/error ' + message);
} else {
this.sendReply('|html|
' + Tools.escapeHTML(message) + '
');
}
};
Context.prototype.sendReplyBox = function (html) {
this.sendReply('|raw|
' + html + '
');
};
Context.prototype.popupReply = function (message) {
this.connection.popup(message);
};
Context.prototype.add = function (data) {
this.room.add(data);
};
Context.prototype.send = function (data) {
this.room.send(data);
};
Context.prototype.privateModCommand = function (data, noLog) {
this.sendModCommand(data);
this.logEntry(data);
this.logModCommand(data);
};
Context.prototype.sendModCommand = function (data) {
let users = this.room.users;
let auth = this.room.auth;
for (let i in users) {
let user = users[i];
// hardcoded for performance reasons (this is an inner loop)
if (user.isStaff || (auth && (auth[user.userid] || '+') !== '+')) {
user.sendTo(this.room, data);
}
}
};
Context.prototype.logEntry = function (data) {
this.room.logEntry(data);
};
Context.prototype.addModCommand = function (text, logOnlyText) {
this.add(text);
this.logModCommand(text + (logOnlyText || ""));
};
Context.prototype.logModCommand = function (text) {
let roomid = (this.room.battle ? 'battle' : this.room.id);
if (this.room.isPersonal) roomid = 'groupchat';
writeModlog(roomid, '(' + this.room.id + ') ' + text);
};
Context.prototype.globalModlog = function (action, user, text) {
let buf = "(" + this.room.id + ") " + action + ": ";
if (typeof user === 'string') {
buf += "[" + toId(user) + "]";
} else {
let userid = this.getLastIdOf(user);
buf += "[" + userid + "]";
if (user.autoconfirmed && user.autoconfirmed !== userid) buf += " ac:[" + user.autoconfirmed + "]";
}
buf += text;
writeModlog('global', buf);
};
Context.prototype.can = function (permission, target, room) {
if (!this.user.can(permission, target, room)) {
this.errorReply(this.cmdToken + this.namespaces.concat(this.cmd).join(" ") + " - Access denied.");
return false;
}
return true;
};
Context.prototype.canBroadcast = function (suppressMessage) {
if (!this.broadcasting && this.cmdToken === BROADCAST_TOKEN) {
let message = this.canTalk(this.message);
if (!message) return false;
if (!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: /" + message.substr(1));
return false;
}
// broadcast cooldown
let normalized = message.toLowerCase().replace(/[^a-z0-9\s!,]/g, '');
if (this.room.lastBroadcast === normalized &&
this.room.lastBroadcastTime >= Date.now() - BROADCAST_COOLDOWN) {
this.errorReply("You can't broadcast this because it was just broadcast.");
return false;
}
this.add('|c|' + this.user.getIdentity(this.room.id) + '|' + (suppressMessage || message));
this.room.lastBroadcast = normalized;
this.room.lastBroadcastTime = Date.now();
this.broadcasting = true;
}
return true;
};
Context.prototype.parse = function (message, inNamespace, room) {
if (inNamespace && this.cmdToken) {
message = this.cmdToken + this.namespaces.concat(message.slice(1)).join(" ");
}
return CommandParser.parse(message, room || this.room, this.user, this.connection, this.levelsDeep + 1);
};
Context.prototype.run = function (targetCmd, inNamespace) {
let commandHandler;
if (typeof targetCmd === 'function') {
commandHandler = targetCmd;
} else if (inNamespace) {
commandHandler = commands;
for (let i = 0; i < this.namespaces.length; i++) {
commandHandler = commandHandler[this.namespaces[i]];
}
commandHandler = commandHandler[targetCmd];
} else {
commandHandler = commands[targetCmd];
}
let result;
try {
result = commandHandler.call(this, this.target, this.room, this.user, this.connection, this.cmd, this.message);
} catch (err) {
let stack = err.stack + '\n\n' +
'Additional information:\n' +
'user = ' + this.user.name + '\n' +
'room = ' + this.room.id + '\n' +
'message = ' + this.message;
let fakeErr = {stack: stack};
if (!require('./crashlogger.js')(fakeErr, 'A chat command')) {
let ministack = ("" + err.stack).escapeHTML().split("\n").slice(0, 2).join(" ");
if (Rooms.lobby) Rooms.lobby.send('|html|
POKEMON SHOWDOWN HAS CRASHED: ' + ministack + '
');
} else {
this.sendReply('|html|
Pokemon Showdown crashed! Don\'t worry, we\'re working on fixing it.
');
}
}
if (result === undefined) result = false;
return result;
};
Context.prototype.canTalk = function (message, relevantRoom, targetUser) {
let innerRoom = (relevantRoom !== undefined) ? relevantRoom : this.room;
return canTalk.call(this, this.user, innerRoom, this.connection, message, targetUser);
};
Context.prototype.canEmbedURI = function (uri, isRelative) {
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) {
return this.errorReply("URIs must begin with 'https://' or 'http://' or 'data:'");
}
} else {
uri = uri.slice(7);
}
let slashIndex = uri.indexOf('/');
let domain = (slashIndex >= 0 ? uri.slice(0, slashIndex) : uri);
// heuristic that works for all the domains we care about
let secondLastDotIndex = domain.lastIndexOf('.', domain.length - 5);
if (secondLastDotIndex >= 0) domain = domain.slice(secondLastDotIndex + 1);
let 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') {
return this.errorReply("Please don't use URL shorteners.");
}
// unknown URI, allow HTTP to be safe
return 'http://' + uri;
};
Context.prototype.canHTML = function (html) {
html = ('' + (html || '')).trim();
if (!html) return '';
let images = /]*/ig;
let match;
while ((match = images.exec(html))) {
if (this.room.isPersonal && !this.user.can('announce')) {
this.errorReply("Images are not allowed in personal rooms.");
return false;
}
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 false;
}
let srcMatch = /src\s*\=\s*"?([^ "]+)(\s*")?/i.exec(match[0]);
if (srcMatch) {
let uri = this.canEmbedURI(srcMatch[1], true);
if (!uri) return false;
html = html.slice(0, match.index + srcMatch.index) + 'src="' + uri + '"' + html.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') && html.replace(/\s*style\s*=\s*\"?[^\"]*\"\s*>/g, '>').match(/