Index: binaries/data/mods/public/gui/common/moderation/LookupTables.js =================================================================== --- binaries/data/mods/public/gui/common/moderation/LookupTables.js +++ binaries/data/mods/public/gui/common/moderation/LookupTables.js @@ -0,0 +1,75 @@ +// NOTE: Ensure that these lookup tables copied from NetModerationTables.h +// are kept in sync with the C++ engine + +// Also ensure that code that uses these constants is executed after +// the constants are defined. + +// enum class ModerationAction +const ModerationAction = +{ + KICK: 1, + BAN: 2, + KICK_BAN: 3, + MUTE: 4, + HELPER: 5, + UNBAN: 6, + UNMUTE: 7, + UNHELPER: 8, + LIST_BAN: 9, + LIST_MUTE: 10, + LIST_HELPER: 11 +}; + +// enum class ModerationResponseType +const ModerationResponseType = +{ + // No responses, except admin notification of actions by helpers + SILENT: 0, + // Admin notification of actions by helpers, and error messages + INFO: 1, + // Broadcast announcements of successful effects of commands except list requests + ANNOUNCEMENT: 2, +}; + +// enum class ModerationResponseCode +const ModerationResponseCode = +{ + UNUSED: 0, + FAILED: 1, + SUCCESS: 2, + PERMISSION_DENIED: 3, + IDENTIFIER_NOT_ON_SERVER: 4, + IDENTIFIER_NOT_IN_LIST: 5, + IDENTIFIER_ALREADY_IN_LIST: 6, + CANNOT_RESTRICT_HOST: 7, + SYNTAX_ERROR: 8, + CAPACITY_EXCEEDED: 9, + INSUFFICIENT_FILLER_DATA: 10, + LIST: 11 +}; + +// enum class ModerationEffectType +const ModerationEffectType = +{ + IP: 1, + USERNAME: 2, + USERNAME_AND_IP: 3 +}; + +// enum class ModerationIdentifierType +const ModerationIdentifierType = +{ + UNUSED: 0, + IP: 1, + USERNAME_PRESENT: 2, + USERNAME_NOT_PRESENT: 3, + USERNAME_IN_LIST_REGEX: 4, + USERNAME_COMMAND_ISSUER: 5, + GUID: 6 +}; + +deepfreeze(ModerationAction); +deepfreeze(ModerationResponseType); +deepfreeze(ModerationResponseCode); +deepfreeze(ModerationEffectType); +deepfreeze(ModerationIdentifierType); Index: binaries/data/mods/public/gui/common/moderation/ModerationResponseAnnouncementStringifier.js =================================================================== --- binaries/data/mods/public/gui/common/moderation/ModerationResponseAnnouncementStringifier.js +++ binaries/data/mods/public/gui/common/moderation/ModerationResponseAnnouncementStringifier.js @@ -0,0 +1,43 @@ +class ModerationResponseAnnouncementStringifier +{ + constructor() {} + + output(action, responseCode, effectType, username, ip, commandIssuer) + { + let outputFormatString; + + if (responseCode === ModerationResponseCode.SUCCESS) + { + if (username.length > 0) + outputFormatString = this.detailedOutput[action]; + if (outputFormatString !== undefined) + { + return sprintf(outputFormatString, + { + "username": username, + }); + } + + } + return ""; + } +} + +ModerationResponseAnnouncementStringifier.prototype.detailedOutput = {}; + +// Translation: The user has been removed from the game +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.KICK] = translateWithContext("moderation", "%(username)s has been kicked"); +// Translation: The user has been removed from the game and added to the ban list +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.KICK_BAN] = translateWithContext("moderation", "%(username)s has been kicked and banned"); +// Translation: The user has been added to the ban list, and will not be allowed to reconnect to the server. +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.BAN] = translateWithContext("moderation", "%(username)s has been banned"); +// Translation: The user has been removed from the ban list, and will be allowed to reconnect to the server +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.UNBAN] = translateWithContext("moderation", "%(username)s has been unbanned"); +// Translation: The user is not allowed to send messages +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.MUTE] = translateWithContext("moderation", "%(username)s has been muted"); +// Translation: The user is allowed to send messages +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.UNMUTE] = translateWithContext("moderation", "%(username)s has been unmuted"); +// Translation: The user has been designated a moderation helper +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.HELPER] = translateWithContext("moderation", "%(username)s has been added to the list of helpers"); +// Translation: The user has lost the moderation helper status +ModerationResponseAnnouncementStringifier.prototype.detailedOutput[ModerationAction.UNHELPER] = translateWithContext("moderation", "%(username)s has been removed from the list of helpers"); Index: binaries/data/mods/public/gui/common/moderation/ModerationResponseDecoder.js =================================================================== --- binaries/data/mods/public/gui/common/moderation/ModerationResponseDecoder.js +++ binaries/data/mods/public/gui/common/moderation/ModerationResponseDecoder.js @@ -0,0 +1,245 @@ +class ModerationResponseDecoder +{ + ModerationResponseInfoStringifier = new ModerationResponseInfoStringifier(); + ModerationResponseAnnouncementStringifier = new ModerationResponseAnnouncementStringifier(); + + constructor() + { + } + + decode(action, responseType, responseCode, effectType, identifiers) + { + // Since there are many cases to address, we apply the strategy of + // narrowing them down as quickly as possible, based on the input + + // Check for an unused response or a command mistakenly sent + // as a moderation response + if (responseCode === ModerationResponseCode.UNUSED) + { + error("BUG: A moderation response was received, but the response was marked as unused."); + return ""; + } + + // If we have received a moderation response when the requested + // moderation type was silent then don't produce output + if (responseType === ModerationResponseType.SILENT) + // return "DEBUG: (2) Moderation response was blank. This could be normal depending on the input."; + return ""; + + // Announcements must only be successful actions that are not + // responses to requests for the contents of lists + if (responseType === ModerationResponseType.ANNOUNCEMENT && !(responseCode === ModerationResponseCode.SUCCESS)) + // return "DEBUG: (3) Moderation response was blank. This could be normal depending on the input."; + return ""; + + let text = ""; + + // Retrieve the identifiers. So far we will only use the last listed + // identifier of each of three types: username, ip and commandIssuerName + let hashresult = this.makeStringsFromIdentifiersHash(identifiers, false); + let username = hashresult['username']; + let ip = escapeText(hashresult['ip']); + let commandIssuerName = escapeText(hashresult['commandissuer']); + let guid = hashresult['guid']; + + // Try to colorize the player name. This is a modified copy of + // colorizePlayerNameHelper that does not replace the player name with + // "(Unknown player)" + // Note that this relies on the caller replacing the global callback + // colorizePlayernameByGUID() with an appropriate function for the chat panel. + // See gui/common/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatMessages/ClientChat.js::colorizePlayernameByGUID() + // and gui/common/session/messages.js::colorizePlayernameByGUID() + if (username !== "") + { + if (guid !== "") + { + let theUsername = g_PlayerAssignments[guid] ? g_PlayerAssignments[guid].name : ""; + if (theUsername.toLowerCase() === username.toLowerCase()) + { + let thePlayerID = g_PlayerAssignments[guid] ? g_PlayerAssignments[guid].player : -1; + if (thePlayerID > 1) + { + username = colorizePlayernameByGUID(guid); + } + else + username = escapeText(username); + } + else + username = escapeText(username); + } + else + username = escapeText(username); + } + + if (responseType === ModerationResponseType.ANNOUNCEMENT && username === "") + // No point to processing further since we won't be announcing IP addresses + // return "DEBUG: (5) Moderation announcement was blank. This could be normal depending on the input."; + return ""; + + + // Process the simple cases first: successful completion of a command + // that doesn't involve retrieving a list and doesn't involve + // reporting the name of the command issuer + text = ""; + let listName = ""; + if (username !== "" && responseCode === ModerationResponseCode.SUCCESS && + (responseType === ModerationResponseType.ANNOUNCEMENT || + (responseType === ModerationResponseType.INFO && + commandIssuerName === ""))) + { + text = this.ModerationResponseAnnouncementStringifier.output(action, responseCode, effectType, username, ip, commandIssuerName); + } + + // From here on, the only response type allowed is INFO, as it's + // intended for admins to get more detailed successful command + // completion notices. This includes IP addresses, command issuer + // names, list output and error messages + if (text === "" && responseType === ModerationResponseType.INFO) + { + // Process list output + if (text === "") + { + let listTitle = ""; + switch (action) + { + case ModerationAction.LIST_BAN: + // Translation: The list title is a label for the list of users in a moderation list, and it will be inserted where %(listtitle)s is present. In this case the users are disallowed from reconnecting to the server. + listTitle = translateWithContext("moderation", "Ban list"); + break; + case ModerationAction.LIST_MUTE: + // Translation: The list title is a label for the list of users in a moderation list, and it will be inserted where %(listtitle)s is present. In this case the users are disallowed from sending messages. + listTitle = translateWithContext("moderation", "Mute list"); + break; + case ModerationAction.LIST_HELPER: + // Translation: The list title is a label for the list of users in a moderation list, and it will be inserted where %(listtitle)s is present. In this case the users are designated as moderation helpers. + listTitle = translateWithContext("moderation", "Helper list"); + break; + default: + break; + } + if (listTitle !== "" && (responseCode === ModerationResponseCode.SUCCESS || responseCode === ModerationResponseCode.LIST)) + { + // Retrieve all of the identifiers for list output + // TODO: Use internationalized list separator character instead of comma + hashresult = this.makeStringsFromIdentifiersHash(identifiers, true); + // TODO: Offer an option to output a list in JSON format + if (hashresult !== undefined) + { + if (hashresult['username'].length > 0 && hashresult['ip'].length > 0) + { + // Translation: The contents of a moderation list are being output. There are one or more usernames and ip addresses, separated by commas + text = sprintf(translateWithContext("moderation", "%(listtitle)s: Users: %(usernames)s ; IPs: %(ipaddresses)s"), + { + "listtitle": listTitle, + "usernames": hashresult['username'], + "ipaddresses": hashresult['ip'] + }); + } + else if (hashresult['username'].length > 0) + { + // Translation: The contents of a moderation list are being output. There are one or more usernames, separated by commas + text = sprintf(translateWithContext("moderation", "%(listtitle)s: Users: %(usernames)s"), + { + "listtitle": listTitle, + "usernames": hashresult['username'] + }); + } + else if (hashresult['ip'].length > 0) + { + // Translation: The contents of a moderation list are being output. There are one or more IP addresses, separated by commas + text = sprintf(translateWithContext("moderation", "%(listtitle)s: IPs: %(ipaddresses)s"), + { + "listtitle": listTitle, + "ipaddresses": hashresult['ip'] + }); + } + } + } + else + { + text = this.ModerationResponseInfoStringifier.output(action, responseCode, effectType, username, ip, commandIssuerName); + } + } + } + // if (text === "") + // text = "DEBUG: (6) Moderation response was blank. This could be normal depending on the input."; + return text; + } + + /** + * Iterate through every element of the identifiers hash to extract usernames + * and ip addresses + * @return If mode is true, return a string with a list of identifiers. + * If mode is false, return a hash of one username and one ip (the + * first ones encountered) + */ + makeStringsFromIdentifiersHash(identifiers, mode) + { + let username = ""; + let usernames = ""; + let ip = ""; + let ips = ""; + let commandIssuerName = ""; + let guid = ""; + let identifierSize = 0; + if (identifiers !== undefined) + { + let firstusername = true; + let firstip = true; + let i = 0; + for (let x in identifiers) + { + if (identifiers[x] !== undefined) + switch (identifiers[x]['identifierType']) + { + case ModerationIdentifierType.USERNAME_PRESENT: + case ModerationIdentifierType.USERNAME_NOT_PRESENT: + if (username === "") + username = identifiers[x]['identifier']; + if (mode) + { + if (!firstusername) + usernames += translateWithContext("enumeration", ", "); + usernames += identifiers[x]['identifier']; + firstusername = false; + } + break; + case ModerationIdentifierType.IP: + if (ip === "") + ip = identifiers[x]['identifier']; + if (mode) + { + if (!firstip) + ips += translateWithContext("enumeration", ", "); + ips += identifiers[x]['identifier']; + firstip = false; + } + break; + case ModerationIdentifierType.USERNAME_COMMAND_ISSUER: + if (commandIssuerName === "") + commandIssuerName = identifiers[x]['identifier']; + break; + case ModerationIdentifierType.GUID: + if (guid === "") + guid = identifiers[x]['identifier']; + break; + default: + break; + } + + ++i; + } + identifierSize = i; + } + + let thehashresult = + { + 'username': mode ? usernames : username, + 'ip': mode ? ips : ip, + 'commandissuer': commandIssuerName, + 'guid': guid + } + return thehashresult; + } +} + Index: binaries/data/mods/public/gui/common/moderation/ModerationResponseInfoStringifier.js =================================================================== --- binaries/data/mods/public/gui/common/moderation/ModerationResponseInfoStringifier.js +++ binaries/data/mods/public/gui/common/moderation/ModerationResponseInfoStringifier.js @@ -0,0 +1,107 @@ +class ModerationResponseInfoStringifier +{ + constructor() + { + } + + + output(action, responseCode, effectType, username, ip, commandIssuer) + { + // Translation: This is a noun that describes a missing or non-applicable username. It will be inserted in a response to a moderation command at %(username)s + username = username.length > 0 ? username : translateWithContext("moderation", "(no username)"); + // Translation: This is a noun that describes a missing or non-applicable IP address. It will be inserted in a response to a moderation command at %(ipaddress)s + ip = ip.length > 0 ? ip : translateWithContext("moderation", "(no IP)"); + // Translation: This is a noun that describes the user that issued the moderation command. In this case it is the person who is hosting the game. The noun will be inserted in a response to a moderation command at %(commandIssuer)s + commandIssuer = commandIssuer.length > 0 ? commandIssuer : translateWithContext("moderation", "(host)"); + + let outputFormatString; + + if (responseCode === ModerationResponseCode.SUCCESS) + { + if (username.length > 0 || ip.length > 0) + outputFormatString = this.detailedOutput[action]; + if (outputFormatString !== undefined) + { + let effectString = this.effectOutput[effectType]; + if (effectString === undefined) + { + error("BUG: Unknown effect type in moderation response"); + // Translation: effectString is a noun that describes the types of identifiers (e.g. usernames or IP addresses) that are affected by a moderation action. In this case the effect is unknown. + effectString = translateWithContext("moderation", "unknown effect"); + } + + return sprintf(outputFormatString, + { + "username": username, + "ipaddress": ip, + "effect": effectString, + "commandissuer": commandIssuer + }); + } + + return this.genericOutput[ModerationResponseCode.SUCCESS]; + } + else + { + outputFormatString = this.genericOutput[responseCode]; + if (outputFormatString !== undefined) + return outputFormatString; + + error("BUG: Unknown moderation response."); + return ""; + } + } +} + +ModerationResponseInfoStringifier.prototype.detailedOutput = {}; + +// Translation: The user has been removed from the game +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.KICK] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been kicked by %(commandissuer)s"); +// Translation: The user has been removed from the game and added to the ban list +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.KICK_BAN] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been kicked and banned by %(commandissuer)s"); +// Translation: The user has been added to the ban list, and will not be allowed to reconnect to the server. +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.BAN] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been banned by %(commandissuer)s"); +// Translation: The user has been removed from the ban list, and will be allowed to reconnect to the server +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.UNBAN] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been unbanned by %(commandissuer)s"); +// Translation: The user is not allowed to send messages +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.MUTE] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been muted by %(commandissuer)s"); +// Translation: The user is allowed to send messages +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.UNMUTE] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been unmuted by %(commandissuer)s"); +// Translation: The user has been designated a moderation helper +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.HELPER] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been added to the list of helpers by %(commandissuer)s"); +// Translation: The user has lost the moderation helper status +ModerationResponseInfoStringifier.prototype.detailedOutput[ModerationAction.UNHELPER] = translateWithContext("moderation", "%(username)s and IP %(ipaddress)s, with effect on %(effect)s, have been removed from the list of helpers by %(commandissuer)s"); + +ModerationResponseInfoStringifier.prototype.genericOutput = {}; + +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.UNUSED] = ""; +// Translation: The moderation command, such as kick or ban, was unsuccessful +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.FAILED] = translateWithContext("moderation", "Error: The command failed."); +// Translation: The moderation command, such as kick or ban, was successful +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.SUCCESS] = translateWithContext("moderation", "The command was successful."); +// Translation: The command issuer does not have permission to issue the moderation command +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.PERMISSION_DENIED] = translateWithContext("moderation", "Error: Permission denied."); +// Translation: The moderation command was unsuccessful because the identifier (for example username or IP address) was not found on the server +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.IDENTIFIER_NOT_ON_SERVER] = translateWithContext("moderation", "Error: The command could not be completed. The identifier was not found on the server."); +// Translation: The moderation command was unsuccessful because the identifier (for example username or IP address) was not found in the moderation list +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.IDENTIFIER_NOT_IN_LIST] = translateWithContext("moderation", "Error: The command could not be completed. The identifier was not found in the list."); +// Translation: The moderation command was unsuccessful because the identifier (for example username or IP address) is already present in the moderation list +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.IDENTIFIER_ALREADY_IN_LIST] = translateWithContext("moderation", "Error: The command could not be completed. The identifier is already in the list."); +// Translation: The moderation command was unsuccessful because it would place a restriction on the user that is hosting the game +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.CANNOT_RESTRICT_HOST] = translateWithContext("moderation", "Error: The command could not be completed. Cannot restrict the host."); +// Translation: The moderation command was unsuccessful because the command was not entered correctly, or the requested effects are not available +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.SYNTAX_ERROR] = translateWithContext("moderation", "Error: Syntax error or unimplemented combination of identifier type and effect type"); +// Translation: The moderation command was unsuccessful because a moderation list memory usage, moderation list element count, or amount of network data transfer would exceed certain limits that have been established. The purpose of these limits is to maintain performance and reliability of the software +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.CAPACITY_EXCEEDED] = translateWithContext("moderation", "Error: A capacity limit would be exceeded if the command was processed"); +// Translation: The moderation command was unsuccessful because the command sender did not include enough filler (unused) data in the request compared to the size of the expected response. This is intended to prevent network request amplification of moderation list output. +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.INSUFFICIENT_FILLER_DATA] = translateWithContext("moderation", "Error: Insufficient filler data provided."); +ModerationResponseInfoStringifier.prototype.genericOutput[ModerationResponseCode.LIST] = ""; + +ModerationResponseInfoStringifier.prototype.effectOutput = {}; + +// Translation: This is a noun that describes the effect of a moderation command. In this case it is an IP address. +ModerationResponseInfoStringifier.prototype.effectOutput[ModerationEffectType.IP] = translateWithContext("moderation", "IP address"); +// Translation: This is a noun that describes the effect of a moderation command. In this case it is the name of a user +ModerationResponseInfoStringifier.prototype.effectOutput[ModerationEffectType.USERNAME] = translateWithContext("moderation", "username"); +// Translation: This is a noun that describes the effect of a moderation command. In this case it is the name of a user and an IP address +ModerationResponseInfoStringifier.prototype.effectOutput[ModerationEffectType.USERNAME_AND_IP] = translateWithContext("moderation", "username and IP address"); Index: binaries/data/mods/public/gui/common/moderation/send_moderation_command.js =================================================================== --- binaries/data/mods/public/gui/common/moderation/send_moderation_command.js +++ binaries/data/mods/public/gui/common/moderation/send_moderation_command.js @@ -0,0 +1,302 @@ +var g_NetworkCommands = { + "/ban-ni": argument => moderation(ModerationAction.KICK_BAN, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/ban-n": argument => moderation(ModerationAction.BAN, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/ban-i": argument => moderation(ModerationAction.BAN, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/kick": argument => moderation(ModerationAction.KICK, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/kick-ni": argument => moderation(ModerationAction.KICK, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/kick-n": argument => moderation(ModerationAction.KICK, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/kickban-ni": argument => moderation(ModerationAction.KICK_BAN, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/kickban-n": argument => moderation(ModerationAction.KICK_BAN, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/mute-ni": argument => moderation(ModerationAction.MUTE, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/mute-n": argument => moderation(ModerationAction.MUTE, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/mute-i": argument => moderation(ModerationAction.MUTE, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/helper-ni": argument => moderation(ModerationAction.HELPER, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_PRESENT, argument), + "/helper-i": argument => moderation(ModerationAction.HELPER, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/helper-n": argument => moderation(ModerationAction.HELPER, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/unban-n": argument => moderation(ModerationAction.UNBAN, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/unban-i": argument => moderation(ModerationAction.UNBAN, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/unmute-n": argument => moderation(ModerationAction.UNMUTE, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/unmute-i": argument => moderation(ModerationAction.UNMUTE, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/unhelper-n": argument => moderation(ModerationAction.UNHELPER, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/unhelper-i": argument => moderation(ModerationAction.UNHELPER, -1, ModerationEffectType.IP, ModerationIdentifierType.IP, argument), + "/listban": argument => moderation(ModerationAction.LIST_BAN, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/listmute": argument => moderation(ModerationAction.LIST_MUTE, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/listhelper": argument => moderation(ModerationAction.LIST_HELPER, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_NOT_PRESENT, argument), + "/help": argument => moderationHelp(), + "/kickspecs": argument => kickObservers(false), + "/banspecs": argument => kickObservers(true), + "/list": argument => g_callbackAddChatText({ "type": "clientlist" }), + "/listusers": argument => g_callbackAddChatText({ "type": "clientlist" }), + "/clear": argument => clearChatMessages() +}; + +// Function pointer for adding chat text to the chat window. This is GUI and class-dependent +function g_callbackAddChatText() +{ + // This is replaced with an actual function by calling executeNetworkCommand() with the appropriate argument +}; + +function kickPlayer(username, ban) +{ + if (!ban) + moderation(ModerationAction.KICK, -1, ModerationEffectType.USERNAME, ModerationIdentifierType.USERNAME_PRESENT, username); + else + moderation(ModerationAction.KICK_BAN, -1, ModerationEffectType.USERNAME_AND_IP, ModerationIdentifierType.USERNAME_PRESENT, username); +} + +function kickObservers(ban) +{ + for (let guid in g_PlayerAssignments) + if (g_PlayerAssignments[guid].player == -1) + kickPlayer(g_PlayerAssignments[guid].name, ban); +} + +function moderationHelp() +{ + // Help was requested. Send moderation responses with the syntax of the moderation commands + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Syntax: /[list|clear]") + }); + if (!g_IsController) + { + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Syntax: (for moderation helpers) /[listmute]") + }); + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Syntax: (for moderation helpers) /[mute-n|unmute-n] username") + }); + } + else /* g_IsController */ + { + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Syntax: /[listban|listmute|listhelper|kickspecs|banspecs]") + }); + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Syntax: /[kick|ban|unban|mute|unmute|helper|unhelper](-ni|-n|-i) (identifier)") + }); + } + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Note 1: Helpers can only work with usernames that are present on the server.") + }); + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Note 2: After a username is (optionally) checked for presence on the server, it is converted to lowercase with no rating for storage or comparison with list contents.") + }); + if (g_IsController) + { + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Note 3: The -n variant of each command has effect on name only, and does not require the user to be present (except see note above about Moderation Helpers). The -i variant has effect on the supplied IP only. The -ni variant has effect on name and IP, and requires the identified user to be present on the server.") + }); + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Example (this adds the name and IP of the user to the ban list and then kicks the user): /kickban-ni JohnDoe") + }); + } + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("Example (this adds the name, which doesn't need to be present, to the mute list): /mute-n disruptive_dude (customRating)") + }); + return false; +} + +// Returns: true if a command other than help or list was successful +// false, otherwise +function moderation(action, responseType, effectType, identifierType, identifier) +{ + // Permission to use moderation commands depends on more than just + // whether the client is the controller of the game. There are lobby + // helpers with mute capability. Permissions are enforced by the + // server. + + let result = false; + + + /* + // If effectType is unset then for the controller default to effectType + // both for certain actions, otherwise default to effectType username + if (effectType === -1) + { + if (g_IsController) + { + if (action === ModerationAction.BAN || + action === ModerationAction.KICK_BAN || + action === ModerationAction.LIST_BAN || + action === ModerationAction.LIST_MUTE || + action === ModerationAction.LIST_HELPER) + effectType = ModerationEffectType.USERNAME_AND_IP; + else + effectType = ModerationEffectType.USERNAME; + } + else + effectType = ModerationEffectType.USERNAME; + } + */ + + // Non-controllers can only operate on usernames and users that are present + if (!g_IsController) + { + if (effectType === ModerationEffectType.USERNAME_AND_IP) + effectType = ModerationEffectType.USERNAME; + if (identifierType === ModerationIdentifierType.USERNAME_NOT_PRESENT) + identifierType = ModerationIdentifierType.USERNAME_PRESENT; + } + + if (responseType === -1) + { + if (action === ModerationAction.LIST_BAN || + action === ModerationAction.LIST_MUTE || + action === ModerationAction.LIST_HELPER) + // The only allowed response type for list or help is info only + responseType = ModerationResponseType.INFO; + else + // The default response type for other commands is info and announcement + responseType = ModerationResponseType.ANNOUNCEMENT; + } + + let identifiers = {}; + + identifiers['0'] = + { + "identifierType": identifierType, + "identifier": identifier + }; + + // Ensure that there is an argument for all commands except help and those that output the contents of lists + if ((action === ModerationAction.LIST_BAN || + action === ModerationAction.LIST_MUTE) || + action === ModerationAction.LIST_HELPER || + (identifiers !== undefined && Object.keys(identifiers).length > 0)) + { + // Check if the client is the game controller or else the + // commands are allowed by users in the 'helpers' list. The + // engine will enforce the permissions properly + if (g_IsController || ((identifierType === ModerationIdentifierType.USERNAME_PRESENT || + identifierType === ModerationIdentifierType.USERNAME_NOT_PRESENT) && + (effectType === ModerationEffectType.USERNAME) && + (action === ModerationAction.MUTE || action === ModerationAction.UNMUTE || + action === ModerationAction.LIST_MUTE))) + { + // Send the message to the server to perform the actual moderation action + let moderationIdentifierContainer = { }; + Object.defineProperty(moderationIdentifierContainer, "identifiers", { + "value": deepfreeze(identifiers) + }); + /* + // For debugging, locally output the identifiers that will be sent + let str = JSON.stringify(identifiers, null, 2); + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText(sprintf("%s", str)) + }); + */ + Engine.Moderation(action, 1 /* true */, responseType, ModerationResponseCode.UNUSED, + effectType, moderationIdentifierContainer.identifiers); + } + else + { + moderationPermissionError(); + return false; + } + + } + else + { + g_callbackAddChatText({ + "type": "moderationresponse", + // Translation: The moderation command was unsuccessful because the command was not entered correctly, or the requested effects are not available + "text": escapeText(translateWithContext("moderation", "Error: Syntax error or unimplemented combination of identifier type and effect type")) + }); + } +} + +function moderationPermissionError() +{ + g_callbackAddChatText({ + "type": "moderationresponse", + // Translation: The command issuer does not have permission to issue the moderation command + "text": escapeText(translateWithContext("moderation", "Error: Permission denied.")) + }); +} + +/** + * Execute a command locally. + * + * @param {string} input + * @param {function} callbackAddChatText - a function to add text to the chat + * messages panel + * @return {boolean} whether a command was executed + */ +function executeNetworkCommand(input, callbackAddChatText) +{ + if (!g_IsNetworked) + return false; + if (input.indexOf("/") != 0) + { + // No frontslash detected, so not a command. return false so + // that the message can be processed by the other handlers. + return false; + } + + let command = input.split(" ", 1)[0]; + let argument = input.substr(command.length + 1); + g_callbackAddChatText = callbackAddChatText; + switch (command) + { + case "/observers": + case "/allies": + case "/enemies": + case "/msg": + case "/me": + if (argument.indexOf("/") != 0) + { + // We don't handle chat-related commands here. Pass + // them on to the chat message sender + return false; + } + else + { + // A command was found after a special chat message. + // This is likely an administrator mistake, pressing T + // to start typing a command. + + g_callbackAddChatText({ + "type": "moderationresponse", + // Translation: A command (signaled by a frontslash) was detected after another command. For safety, it was not sent as a chat message + "text": escapeText(translateWithContext("moderation", "A command was found after a chat command. It was not processed, and the chat message was not sent.")) + }); + return true; + } + default: + // Any other text starting with a frontslash should be consumed and not sent as a chat message. + // On second thought, let's return false and let the next handler handle it. + break; + } + + if (!g_NetworkCommands[command]) + { + // The command was not recognized as a moderation command. Pass it on to the other handlers + return false; + } + else + { + // Echo back the command that was typed by the user for easier editing + g_callbackAddChatText({ + "type": "moderationresponse", + "text": escapeText("> " + input) + }); + g_NetworkCommands[command](argument); + } + + // The command was recognized as a moderation command. It either + // completed successfully or resulted in a syntax error. Indicate to + // the caller that the command was handled so that other handlers + // don't try to process it (such as sending it as a chat message). + return true; +} Index: binaries/data/mods/public/gui/common/network.js =================================================================== --- binaries/data/mods/public/gui/common/network.js +++ binaries/data/mods/public/gui/common/network.js @@ -36,15 +36,6 @@ }) }; -var g_NetworkCommands = { - "/kick": argument => kickPlayer(argument, false), - "/ban": argument => kickPlayer(argument, true), - "/kickspecs": argument => kickObservers(false), - "/banspecs": argument => kickObservers(true), - "/list": argument => addChatMessage({ "type": "clientlist" }), - "/clear": argument => clearChatMessages() -}; - var g_ValidPorts = { "min": 1024, "max": 65535 }; // TODO: This check should be performed exclusively on the C++ side, currently this is sort of redundant. function getValidPort(port) @@ -102,35 +93,6 @@ ); } -function kickError() -{ - addChatMessage({ - "type": "system", - "text": translate("Only the host can kick clients!") - }); -} - -function kickPlayer(username, ban) -{ - if (g_IsController) - Engine.KickPlayer(username, ban); - else - kickError(); -} - -function kickObservers(ban) -{ - if (!g_IsController) - { - kickError(); - return; - } - - for (let guid in g_PlayerAssignments) - if (g_PlayerAssignments[guid].player == -1) - Engine.KickPlayer(g_PlayerAssignments[guid].name, ban); -} - /** * Sort GUIDs of connected users sorted by playerindex, observers last. * Requires g_PlayerAssignments. @@ -167,26 +129,6 @@ } /** - * Execute a command locally. Requires addChatMessage. - * - * @param {string} input - * @returns {boolean} whether a command was executed - */ -function executeNetworkCommand(input) -{ - if (input.indexOf("/") != 0) - return false; - - let command = input.split(" ", 1)[0]; - let argument = input.substr(command.length + 1); - - if (g_NetworkCommands[command]) - g_NetworkCommands[command](argument); - - return !!g_NetworkCommands[command]; -} - -/** * Remember this warning for a few seconds. * Overwrite previous warnings for this user. * Index: binaries/data/mods/public/gui/credits/texts/programming.json =================================================================== --- binaries/data/mods/public/gui/credits/texts/programming.json +++ binaries/data/mods/public/gui/credits/texts/programming.json @@ -200,6 +200,7 @@ { "nick": "nikagra", "name": "Mikita Hradovich" }, { "nick": "njm" }, { "nick": "NoMonkey", "name": "John Mena" }, + { "nick": "Norse_Harold" }, { "nick": "norsnor" }, { "nick": "notpete", "name": "Rich Cross" }, { "nick": "Nullus" }, Index: binaries/data/mods/public/gui/gamesetup/NetMessages/NetMessages.js =================================================================== --- binaries/data/mods/public/gui/gamesetup/NetMessages/NetMessages.js +++ binaries/data/mods/public/gui/gamesetup/NetMessages/NetMessages.js @@ -57,5 +57,6 @@ "netstatus", "netwarn", "players", - "start" + "start", + "moderation-response" ]; Index: binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatInputPanel.js =================================================================== --- binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatInputPanel.js +++ binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatInputPanel.js @@ -1,8 +1,9 @@ class ChatInputPanel { - constructor(netMessages, chatInputAutocomplete) + constructor(netMessages, chatInputAutocomplete, executeNetworkCommandInterface) { this.chatInputAutocomplete = chatInputAutocomplete; + this.executeNetworkCommandInterface = executeNetworkCommandInterface; this.chatInput = Engine.GetGUIObjectByName("chatInput"); this.chatInput.tooltip = colorizeAutocompleteHotkey(this.Tooltip); @@ -44,8 +45,20 @@ this.chatInput.caption = ""; - if (!executeNetworkCommand(text)) + if (text.startsWith('/')) + { + const cmd = text.split(/\s/)[0]; + if (!this.executeNetworkCommandInterface(text)) + { + // None of the chat commands are recognized in this chat window. + // Warn the user and don't send the command as a message. + warn("Unknown chat command: " + cmd); + } + } + else + { Engine.SendNetworkChat(text); + } this.chatInput.focus(); } Index: binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatMessagesPanel.js =================================================================== --- binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatMessagesPanel.js +++ binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatMessagesPanel.js @@ -35,6 +35,11 @@ this.chatText.addItem(text); } + addMessageText(msg) + { + this.addText(msg.text); + } + addStatusMessage(text) { this.addText(this.statusMessageFormat.format(text)); Index: binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatPanel.js =================================================================== --- binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatPanel.js +++ binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Chat/ChatPanel.js @@ -19,11 +19,72 @@ this.chatInputAutocomplete = new ChatInputAutocomplete( gameSettingControlManager, setupWindow.controls.gameSettingsController, setupWindow.controls.playerAssignmentsController); + this.moderationResponseDecoder = new ModerationResponseDecoder(); + + // Pass a callback for executeNetworkCommand that is customized to use the appropriate callback for adding chat text to the messages panel this.chatInputPanel = new ChatInputPanel( - setupWindow.controls.netMessages, this.chatInputAutocomplete); + setupWindow.controls.netMessages, this.chatInputAutocomplete, this.executeNetworkCommandInterface.bind(this)); this.chatMessageEvents = []; for (let name in ChatMessageEvents) this.chatMessageEvents.push(new ChatMessageEvents[name](setupWindow, this.chatMessagesPanel)); + setupWindow.controls.netMessages.registerNetMessageHandler("moderation-response", this.onModerationResultMessage.bind(this)); + } + + executeNetworkCommandInterface(command) + { + return executeNetworkCommand(command, this.chatMessagesPanel.addMessageText.bind(this.chatMessagesPanel)); + } + + onModerationResultMessage(msg) + { + if (msg.responseCode != ModerationResponseCode.UNUSED && !msg.actionIsCommand) + { + // Convert the moderation response codes to a human-readable string of text for output in the chat message window + const text = this.moderationResponseDecoder.decode(msg.action, msg.responseType, msg.responseCode, msg.effectType, msg.identifiers); + // decode already applies escapeText to user-provided input. + // We don't want to escape the entire moderation response because that would + // disrupt colorization of player names + if (msg.responseType === ModerationResponseType.ANNOUNCEMENT) + { + // TODO: Format the message as a status message if it's an announcement so that + // it is uniform with join, leave and kick messages. + // However, this would need to be special-cased for gamesetup chat, because + // session chat has a different way of formatting kicked messages + // this.chatMessagesPanel.addStatusMessage + this.chatMessagesPanel.addMessageText({ + "type": "moderationresponse", + "text": text + }); + } + else if (msg.responseType === ModerationResponseType.INFO) + { + this.chatMessagesPanel.addMessageText({ + "type": "moderationresponse", + "text": text + }); + } + else + { + /* + let text = "DEBUG: Displaying a moderation response despite it not passing the filter: " + this.moderationResponseDecoder.decode(msg.action, msg.responseType, msg.responseCode, msg.effectType, msg.identifiers); + this.chatMessagesPanel.addMessageText({ + "type": "moderationresponse", + "text": text + }); + */ + } + } + else + { + /* + let text = "DEBUG: Displaying a moderation response despite it not passing the filter: " + this.moderationResponseDecoder.decode(msg.action, msg.responseType, msg.responseCode, msg.effectType, msg.identifiers); + text = escapeText(text); + this.chatMessagesPanel.addMessageText({ + "type": "moderationresponse", + "text": text + }); + */ + } } } Index: binaries/data/mods/public/gui/gamesetup/gamesetup.xml =================================================================== --- binaries/data/mods/public/gui/gamesetup/gamesetup.xml +++ binaries/data/mods/public/gui/gamesetup/gamesetup.xml @@ -2,6 +2,7 @@