diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index d635595..0000000 --- a/.github/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# GM Command Module - -This module serves as a reusable DB query to modify security requirements for GM commands with AzerothCore, specifically intended for use by ChromieCraft's live server. diff --git a/README.md b/README.md new file mode 100644 index 0000000..591b3d8 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# GM Commands Module + +This module lets you curate the set of chat commands that selected accounts can use without granting them the full GM security level. It hooks into the AzerothCore command system to hide or block commands that are not explicitly whitelisted, while still enforcing the regular command security for every other player. + +## Capabilities +- Manage a list of account IDs controlled by the module. +- Assign a shared default GM level and default command list for those accounts. +- Override the GM level or allowed commands per account. +- Allow commands that normally require a higher security level when they are explicitly whitelisted. +- Always allow commands that require security level `SEC_PLAYER` (0). + +## Configuration Files +The module looks for its configuration in both `modules/mod_gm_commands.conf.dist` and `modules/mod_gm_commands.conf` inside your server configuration directory (for example `env/dist/etc/modules/`). + +When the server starts (or the config is reloaded), the module reads `.conf.dist` first and then `.conf`. Keys present in the non-`.dist` file override the defaults. This allows you to keep personal changes out of version control while still benefiting from distribution updates. + +### Required Keys +- `GmCommandsModule.Enable`: Set to `1` to activate the module. +- `GmCommandsModule.AccountIds`: Comma-separated list of account IDs that the module manages. Only accounts in this list are affected. + +### Optional Defaults +- `GmCommandsModule.DefaultLevel`: GM level applied to managed accounts that do not define their own level. Clamped between `SEC_PLAYER (0)` and `SEC_ADMINISTRATOR (3)`. +- `GmCommandsModule.DefaultCommands`: Comma-separated list of commands granted to managed accounts that do not define their own command list. Commands that require level 0 are still automatically available even if they are not listed. + +### Per-Account Overrides +Use the following keys to override defaults for a specific account, replacing `` with the numeric ID: +- `GmCommandsModule.Account..Level` +- `GmCommandsModule.Account..Commands` + +Both files (`mod_gm_commands.conf.dist` and `mod_gm_commands.conf`) are scanned, so you may define an override in either place. Values found in the non-`.dist` file take precedence. + +#### Command List Formatting +Commands are stored in a normalized, lowercase form: +- Trim spaces around each entry. +- Collapse multiple inner spaces to a single space. +- Use the full chat command path. For example, the command shown in chat as `.character level` must be written as `character level`; `levelup` can be listed as-is because it is a top-level command. + +You can list multi-word commands exactly as they appear after the leading dot, separated by commas: +``` +GmCommandsModule.Account.3.Commands = "character level, character rename, levelup, gm visible" +``` + +## Runtime Behaviour +- When a GM account uses a command, the module records the command name and its required security level. +- If an account is in the managed list and the command requires more than `SEC_PLAYER`, the module checks the whitelist before the core performs its visibility/security check. +- Whitelisted commands return early from the visibility hook, effectively bypassing the security-level requirement for that account. Non-whitelisted commands continue through the normal core checks and are blocked with “You are not allowed to use this command.” +- The module logs the resolved default settings and per-account overrides at startup (log channel `modules.gmcommands`) to aid troubleshooting. + +## Reloading Configuration +After editing the configuration, either restart the worldserver or run `.reload config` from a GM account with adequate privileges. The module will re-read both configuration files and log the updated account summaries. + +## Troubleshooting +- Ensure the account ID is listed in `GmCommandsModule.AccountIds`; otherwise the account is ignored. +- Use the full command name (including subcommand structure) in the whitelist. If a command still reports “does not exist,” verify the spelling and normalization. +- Check the worldserver log for `modules.gmcommands` entries to confirm that the module picked up your overrides. +- Commands that require only `SEC_PLAYER` never need to be listed; if users cannot run them, the issue lies elsewhere (permissions, syntax, etc.). + +## Example +``` +GmCommandsModule.Enable = 1 +GmCommandsModule.AccountIds = "3,4" +GmCommandsModule.DefaultLevel = 0 +GmCommandsModule.DefaultCommands = "go" + +GmCommandsModule.Account.3.Level = 1 +GmCommandsModule.Account.3.Commands = "character level, levelup, gm, gm visible" + +GmCommandsModule.Account.4.Commands = "appeal, go, ticket" +``` + +In this example account 3 keeps security level 1 but can run `character level` and `levelup`, while account 4 inherits the default security level (0) yet receives a custom command list. + +## Development Notes +The implementation lives in `src/GmCommands.cpp` and exposes a singleton (`sGMCommands`) responsible for: +- Loading overrides from `sConfigMgr` and the module config files. +- Normalizing command names and caching per-command required security. +- Hooking `AllCommandScript` to hide or allow commands based on the configured whitelist. + +Any changes to the command registry should keep the normalization logic in mind so that configuration values remain compatible. diff --git a/conf/mod_gm_commands.conf.dist b/conf/mod_gm_commands.conf.dist index 0334bd3..fca1699 100644 --- a/conf/mod_gm_commands.conf.dist +++ b/conf/mod_gm_commands.conf.dist @@ -18,17 +18,41 @@ GmCommandsModule.Enable = 1 # # GmCommandsModule.AccountIds -# Description: Allow the specified accounts to use commands. Separated by comma -# +# Description: Comma separated list of account identifiers that should be managed by this module. +# Each listed account can optionally define a custom GM level or custom command list. # GmCommandsModule.AccountIds = "" # +# GmCommandsModule.DefaultLevel +# Description: Default GM level assigned to managed accounts that do not define their own level. +# The value is clamped between SEC_PLAYER (0) and SEC_ADMINISTRATOR (3). +# Default: 0 - SEC_PLAYER # -# GmCommandsModule.AllowedCommands -# Description: Allow the specified commands to be used. Separated by comma. + +GmCommandsModule.DefaultLevel = 0 + # +# GmCommandsModule.DefaultCommands +# Description: Default comma separated list of commands (case-insensitive) allowed to managed accounts +# when a specific command list is not defined for the account. Commands that require +# security level 0 are always allowed. +# Default: "" # -GmCommandsModule.AllowedCommands = "" \ No newline at end of file +GmCommandsModule.DefaultCommands = "" + +# +# Per-account overrides +# +# GmCommandsModule.Account..Level +# Description: Override the GM level for the specified account id. +# Omit this entry to use `GmCommandsModule.DefaultLevel`. +# Example: GmCommandsModule.Account.42.Level = 2 +# +# GmCommandsModule.Account..Commands +# Description: Override the allowed commands for the specified account id. Provide a comma +# separated, case-insensitive list. Leave unset to inherit `GmCommandsModule.DefaultCommands`. +# Example: GmCommandsModule.Account.42.Commands = "gm, gm visible, account" +# diff --git a/src/GmCommands.cpp b/src/GmCommands.cpp index 279a843..d548e93 100644 --- a/src/GmCommands.cpp +++ b/src/GmCommands.cpp @@ -1,11 +1,110 @@ -#include "Player.h" -#include "PlayerScript.h" -#include "ScriptMgr.h" #include "Chat.h" #include "ChatCommand.h" #include "Config.h" #include "GmCommands.h" +#include "Log.h" +#include "Player.h" +#include "PlayerScript.h" +#include "ScriptMgr.h" +#include "StringConvert.h" +#include "StringFormat.h" #include "Tokenize.h" +#include "WorldSession.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr char const* ACCOUNT_IDS_KEY = "GmCommandsModule.AccountIds"; + constexpr char const* DEFAULT_COMMANDS_KEY = "GmCommandsModule.DefaultCommands"; + constexpr char const* DEFAULT_LEVEL_KEY = "GmCommandsModule.DefaultLevel"; + constexpr char const* MODULE_CONFIG_FILE = "mod_gm_commands.conf"; + constexpr char const* MODULE_CONFIG_DIST_FILE = "mod_gm_commands.conf.dist"; + + struct FileAccountOverrides + { + std::optional Level; + std::optional Commands; + }; + + template + T GetOptionWithoutLog(std::string const& name, T const& def) + { + return sConfigMgr->GetOption(name, def, false); + } + + std::unordered_map ReadAccountOverridesFromConfigFiles() + { + std::unordered_map overrides; + + std::string const basePath = Acore::StringFormat("{}modules/", sConfigMgr->GetConfigPath()); + std::array const filenames = + { + basePath + MODULE_CONFIG_DIST_FILE, + basePath + MODULE_CONFIG_FILE + }; + + std::string const prefix = "GmCommandsModule.Account."; + + for (std::string const& file : filenames) + { + std::ifstream stream(file); + if (!stream.is_open()) + continue; + + std::string line; + while (std::getline(stream, line)) + { + line = Acore::String::Trim(line); + if (line.empty() || line.front() == '#' || line.front() == '[') + continue; + + std::size_t const equalPos = line.find('='); + if (equalPos == std::string::npos) + continue; + + std::string key = Acore::String::Trim(line.substr(0, equalPos)); + if (key.rfind(prefix, 0) != 0) + continue; + + std::string value = Acore::String::Trim(line.substr(equalPos + 1)); + value.erase(std::remove(value.begin(), value.end(), '"'), value.end()); + + std::string_view const suffix = std::string_view(key).substr(prefix.size()); + std::size_t const dotPos = suffix.find('.'); + if (dotPos == std::string::npos) + continue; + + std::string accountString(suffix.substr(0, dotPos)); + std::string_view const field = suffix.substr(dotPos + 1); + + std::optional accountIdOpt = Acore::StringTo(accountString); + if (!accountIdOpt) + continue; + + FileAccountOverrides& entry = overrides[*accountIdOpt]; + + if (field == "Level") + { + if (std::optional levelOpt = Acore::StringTo(value)) + entry.Level = *levelOpt; + } + else if (field == "Commands") + { + if (!value.empty()) + entry.Commands = value; + } + } + } + + return overrides; + } +} GMCommands* GMCommands::instance() { @@ -13,38 +112,259 @@ GMCommands* GMCommands::instance() return &instance; } -void GMCommands::LoadAccountIds() +void GMCommands::Reload() { - accountIDs.clear(); + _accounts.clear(); + _accountConfigurations.clear(); + _defaultCommands.clear(); + _commandPermissions.clear(); + _lastCommandByHandler.clear(); + + uint32 defaultLevelRaw = sConfigMgr->GetOption(DEFAULT_LEVEL_KEY, SEC_PLAYER); + _defaultLevel = NormalizeLevel(defaultLevelRaw, DEFAULT_LEVEL_KEY); - std::string accountIds = sConfigMgr->GetOption("GmCommandsModule.AccountIds", ""); - for (auto& itr : Acore::Tokenize(accountIds, ',', false)) + std::string defaultCommandsConfig = sConfigMgr->GetOption(DEFAULT_COMMANDS_KEY, ""); + + auto const buildCommandListString = [](CommandSet const& commands) { - uint32 accountId = Acore::StringTo(itr).value(); - accountIDs.push_back(accountId); + if (commands.empty()) + return std::string{}; + + std::ostringstream stream; + bool first = true; + for (std::string const& cmd : commands) + { + if (!first) + stream << ","; + + stream << cmd; + first = false; + } + + return stream.str(); + }; + + for (std::string_view token : Acore::Tokenize(defaultCommandsConfig, ',', false)) + { + std::string normalized = NormalizeCommand(token); + if (!normalized.empty()) + _defaultCommands.insert(std::move(normalized)); } + + LOG_INFO("modules.gmcommands", "GmCommands: default level {} with commands [{}]", _defaultLevel, buildCommandListString(_defaultCommands)); + + std::unordered_map const fileOverrides = ReadAccountOverridesFromConfigFiles(); + + std::string accountIds = sConfigMgr->GetOption(ACCOUNT_IDS_KEY, ""); + for (std::string_view token : Acore::Tokenize(accountIds, ',', false)) + { + std::string trimmed = NormalizeCommand(token); + if (trimmed.empty()) + continue; + + std::optional accountIdOpt = Acore::StringTo(trimmed); + if (!accountIdOpt) + { + LogInvalidAccountId(token); + continue; + } + + uint32 accountId = *accountIdOpt; + + if (!_accounts.insert(accountId).second) + continue; + + AccountConfiguration config; + + std::string levelKey = Acore::StringFormat("GmCommandsModule.Account.{}.Level", accountId); + uint32 levelSentinel = std::numeric_limits::max(); + uint32 levelValue = GetOptionWithoutLog(levelKey, levelSentinel); + if (levelValue != levelSentinel) + config.Level = NormalizeLevel(levelValue, levelKey); + else if (auto const fileOverrideIt = fileOverrides.find(accountId); fileOverrideIt != fileOverrides.end() && fileOverrideIt->second.Level) + config.Level = NormalizeLevel(*fileOverrideIt->second.Level, levelKey); + + std::string commandsKey = Acore::StringFormat("GmCommandsModule.Account.{}.Commands", accountId); + std::string commandList = GetOptionWithoutLog(commandsKey, std::string{}); + if (commandList.empty()) + { + if (auto const fileOverrideIt = fileOverrides.find(accountId); fileOverrideIt != fileOverrides.end() && fileOverrideIt->second.Commands) + commandList = *fileOverrideIt->second.Commands; + } + + if (!commandList.empty()) + { + CommandSet customCommands; + for (std::string_view cmd : Acore::Tokenize(commandList, ',', false)) + { + std::string normalized = NormalizeCommand(cmd); + if (!normalized.empty()) + customCommands.insert(std::move(normalized)); + } + + if (!customCommands.empty()) + config.Commands = std::move(customCommands); + } + + if (config.Level || config.Commands) + { + auto [it, inserted] = _accountConfigurations.emplace(accountId, std::move(config)); + if (inserted) + { + std::string const levelStr = it->second.Level ? std::to_string(*it->second.Level) : ""; + std::string const commandStr = it->second.Commands ? buildCommandListString(*it->second.Commands) : ""; + LOG_INFO("modules.gmcommands", "GmCommands: configured account {} level {} commands [{}]", accountId, levelStr, commandStr); + } + } + else + { + LOG_INFO("modules.gmcommands", "GmCommands: managing account {} with inherited settings", accountId); + } + } + + LOG_INFO("modules.gmcommands", "GmCommands: managing {} accounts", _accounts.size()); +} + +bool GMCommands::IsAccountAllowed(uint32 accountId) const +{ + return _accounts.find(accountId) != _accounts.end(); +} + +AccountTypes GMCommands::GetAccountLevel(uint32 accountId) const +{ + if (!IsAccountAllowed(accountId)) + return SEC_PLAYER; + + auto const configIt = _accountConfigurations.find(accountId); + if (configIt != _accountConfigurations.end() && configIt->second.Level) + return *configIt->second.Level; + + return _defaultLevel; +} + +bool GMCommands::IsCommandAllowed(uint32 accountId, std::string_view command) const +{ + if (!IsAccountAllowed(accountId)) + return false; + + std::string normalized = NormalizeCommand(command); + if (normalized.empty()) + return false; + + // Commands that require SEC_PLAYER (0) are always allowed + auto const permissionIt = _commandPermissions.find(normalized); + if (permissionIt != _commandPermissions.end() && permissionIt->second <= SEC_PLAYER) + return true; + + CommandSet const& commands = GetCommandSetForAccount(accountId); + return commands.find(normalized) != commands.end(); } -void GMCommands::LoadAllowedCommands() +void GMCommands::RememberCommandMetadata(std::string_view command, uint32 requiredLevel) { - allowedCommands.clear(); + std::string normalized = NormalizeCommand(command); + if (normalized.empty()) + return; - std::string allowedCommandsList = sConfigMgr->GetOption("GmCommandsModule.AllowedCommands", ""); - for (auto& itr : Acore::Tokenize(allowedCommandsList, ',', false)) + _commandPermissions[normalized] = requiredLevel; +} + +void GMCommands::RememberHandlerCommand(ChatHandler const* handler, std::string_view command) +{ + if (!handler) + return; + + std::string normalized = NormalizeCommand(command); + if (normalized.empty()) + return; + + _lastCommandByHandler[handler] = std::move(normalized); +} + +std::optional GMCommands::GetHandlerCommand(ChatHandler const* handler) const +{ + if (!handler) + return std::nullopt; + + auto const it = _lastCommandByHandler.find(handler); + if (it == _lastCommandByHandler.end()) + return std::nullopt; + + return it->second; +} + +std::optional GMCommands::GetCommandRequiredLevel(std::string_view command) const +{ + std::string normalized = NormalizeCommand(command); + if (normalized.empty()) + return std::nullopt; + + auto const it = _commandPermissions.find(normalized); + if (it == _commandPermissions.end()) + return std::nullopt; + + return it->second; +} + +GMCommands::CommandSet const& GMCommands::GetCommandSetForAccount(uint32 accountId) const +{ + auto const it = _accountConfigurations.find(accountId); + if (it != _accountConfigurations.end() && it->second.Commands) + return *it->second.Commands; + + return _defaultCommands; +} + +std::string GMCommands::NormalizeCommand(std::string_view command) +{ + std::string value(command); + if (value.empty()) + return value; + + value = Acore::String::Trim(value); + + std::string normalized; + normalized.reserve(value.size()); + + bool lastWasSpace = false; + for (char ch : value) { - std::string command(itr); - allowedCommands.push_back(command); + unsigned char uch = static_cast(ch); + if (std::isspace(uch)) + { + if (!lastWasSpace) + { + normalized.push_back(' '); + lastWasSpace = true; + } + } + else + { + normalized.push_back(static_cast(std::tolower(uch))); + lastWasSpace = false; + } } + + if (!normalized.empty() && normalized.back() == ' ') + normalized.pop_back(); + + return normalized; } -bool GMCommands::IsAccountAllowed(uint32 accountId) const +AccountTypes GMCommands::NormalizeLevel(uint32 level, std::string_view context) { - return std::find(accountIDs.begin(), accountIDs.end(), accountId) != accountIDs.end(); + if (level > SEC_ADMINISTRATOR) + { + LOG_WARN("modules.gmcommands", "GmCommands: clamping configured level '{}' for '{}' to SEC_ADMINISTRATOR ({}).", level, context, SEC_ADMINISTRATOR); + level = SEC_ADMINISTRATOR; + } + + return static_cast(level); } -bool GMCommands::IsCommandAllowed(std::string command) const +void GMCommands::LogInvalidAccountId(std::string_view token) { - return std::find(allowedCommands.begin(), allowedCommands.end(), command) != allowedCommands.end(); + LOG_WARN("modules.gmcommands", "GmCommands: ignoring invalid account id token '{}'", token); } class GmCommands : public AllCommandScript @@ -54,22 +374,59 @@ class GmCommands : public AllCommandScript bool OnBeforeIsInvokerVisible(std::string name, Acore::Impl::ChatCommands::CommandPermissions permissions, ChatHandler const& who) override { + sGMCommands->RememberCommandMetadata(name, permissions.RequiredLevel); + sGMCommands->RememberHandlerCommand(&who, name); + if (who.IsConsole()) return true; Player* player = who.GetPlayer(); - if (!player) return true; - uint32 accountID = player->GetSession()->GetAccountId(); + WorldSession* session = player->GetSession(); + if (!session) + return true; + + uint32 accountId = session->GetAccountId(); + if (!sGMCommands->IsAccountAllowed(accountId)) + return true; + + if (permissions.RequiredLevel <= SEC_PLAYER) + return true; + + if (!sGMCommands->IsCommandAllowed(accountId, name)) + return true; + + return false; + } + + bool OnTryExecuteCommand(ChatHandler& handler, std::string_view /*cmdStr*/) override + { + if (handler.IsConsole()) + return true; + + WorldSession* session = handler.GetSession(); + if (!session) + return true; + + uint32 accountId = session->GetAccountId(); + if (!sGMCommands->IsAccountAllowed(accountId)) + return true; + + std::optional commandName = sGMCommands->GetHandlerCommand(&handler); + if (!commandName) + return true; - if (!sGMCommands->IsAccountAllowed(accountID)) + std::optional requiredLevel = sGMCommands->GetCommandRequiredLevel(*commandName); + if (requiredLevel && *requiredLevel <= SEC_PLAYER) return true; - if (!sGMCommands->IsCommandAllowed(name)) + if (sGMCommands->IsCommandAllowed(accountId, *commandName)) return true; + handler.SendSysMessage("You are not allowed to use this command."); + handler.SetSentErrorMessage(true); return false; } }; @@ -81,8 +438,7 @@ class mod_gm_commands_worldscript : public WorldScript void OnAfterConfigLoad(bool /*reload*/) override { - sGMCommands->LoadAccountIds(); - sGMCommands->LoadAllowedCommands(); + sGMCommands->Reload(); } }; @@ -91,6 +447,26 @@ class mod_gm_commands_playerscript : public PlayerScript public: mod_gm_commands_playerscript() : PlayerScript("mod_gm_commands_playerscript") {} + void OnPlayerLogin(Player* player) override + { + if (!player) + return; + + WorldSession* session = player->GetSession(); + if (!session) + return; + + uint32 accountId = session->GetAccountId(); + if (!sGMCommands->IsAccountAllowed(accountId)) + return; + + AccountTypes configured = sGMCommands->GetAccountLevel(accountId); + if (session->GetSecurity() == configured) + return; + + session->SetSecurity(configured); + } + void OnPlayerSetServerSideVisibility(Player* player, ServerSideVisibilityType& type, AccountTypes& sec) override { if (type != SERVERSIDE_VISIBILITY_GM) @@ -103,16 +479,18 @@ class mod_gm_commands_playerscript : public PlayerScript if (!session) return; - if (!sGMCommands->IsAccountAllowed(session->GetAccountId())) + uint32 accountId = session->GetAccountId(); + if (!sGMCommands->IsAccountAllowed(accountId)) return; - if (sec != SEC_PLAYER) + AccountTypes configuredLevel = sGMCommands->GetAccountLevel(accountId); + if (configuredLevel <= SEC_PLAYER) return; - if (!sGMCommands->IsCommandAllowed("gm") && !sGMCommands->IsCommandAllowed("gm visible")) + if (sec == configuredLevel) return; - sec = SEC_GAMEMASTER; + sec = configuredLevel; } }; diff --git a/src/GmCommands.h b/src/GmCommands.h index 2f12203..ddef92e 100644 --- a/src/GmCommands.h +++ b/src/GmCommands.h @@ -1,23 +1,51 @@ #ifndef DEF_GMCOMMANDS_H #define DEF_GMCOMMANDS_H -#include "Player.h" -#include "Config.h" +#include "Common.h" +#include +#include +#include +#include + +class ChatHandler; class GMCommands { public: static GMCommands* instance(); - void LoadAccountIds(); - void LoadAllowedCommands(); + void Reload(); [[nodiscard]] bool IsAccountAllowed(uint32 accountId) const; - [[nodiscard]] bool IsCommandAllowed(std::string command) const; + [[nodiscard]] AccountTypes GetAccountLevel(uint32 accountId) const; + [[nodiscard]] bool IsCommandAllowed(uint32 accountId, std::string_view command) const; + + void RememberCommandMetadata(std::string_view command, uint32 requiredLevel); + void RememberHandlerCommand(ChatHandler const* handler, std::string_view command); + [[nodiscard]] std::optional GetHandlerCommand(ChatHandler const* handler) const; + [[nodiscard]] std::optional GetCommandRequiredLevel(std::string_view command) const; private: - std::vector accountIDs; - std::vector allowedCommands; + using CommandSet = std::unordered_set; + + struct AccountConfiguration + { + std::optional Level; + std::optional Commands; + }; + + [[nodiscard]] CommandSet const& GetCommandSetForAccount(uint32 accountId) const; + static std::string NormalizeCommand(std::string_view command); + static AccountTypes NormalizeLevel(uint32 level, std::string_view context); + static void LogInvalidAccountId(std::string_view token); + + AccountTypes _defaultLevel = SEC_PLAYER; + CommandSet _defaultCommands; + std::unordered_set _accounts; + std::unordered_map _accountConfigurations; + + std::unordered_map _commandPermissions; + mutable std::unordered_map _lastCommandByHandler; }; #define sGMCommands GMCommands::instance()