feat(Core/Chat): Add chat filter with Aho-Corasick matching (#26051)

This commit is contained in:
Kitzunu
2026-06-01 22:26:28 +02:00
committed by GitHub
parent 333ba2f9ec
commit 8b8ba243d8
19 changed files with 494 additions and 4 deletions

View File

@@ -0,0 +1,157 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef AhoCorasick_h__
#define AhoCorasick_h__
#include "Define.h"
#include <queue>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace Acore
{
/**
* @class AhoCorasick
*
* @brief Multi-pattern substring matcher.
*
* Insert all patterns, call Build() once, then query in O(text length)
* regardless of pattern count. Insertion after Build() is not supported
* (it would invalidate failure links — call Build() again if you must).
*
* Matching is exact on the @p CharT alphabet — pre-lowercase patterns and
* inputs in the caller if you need case-insensitive matching.
*
* Usage:
* Acore::AhoCorasick<wchar_t> a;
* a.Insert(L"foo");
* a.Insert(L"bar");
* a.Build();
* bool hit = a.ContainsAny(L"the bar is open"); // -> true
*/
template<typename CharT>
class AhoCorasick
{
public:
using StringType = std::basic_string<CharT>;
using StringViewType = std::basic_string_view<CharT>;
AhoCorasick() { _nodes.emplace_back(); }
void Insert(StringViewType pattern)
{
if (pattern.empty())
return;
uint32 cur = 0;
for (CharT c : pattern)
{
auto it = _nodes[cur].next.find(c);
if (it != _nodes[cur].next.end())
{
cur = it->second;
continue;
}
uint32 idx = static_cast<uint32>(_nodes.size());
_nodes.emplace_back();
_nodes[cur].next.emplace(c, idx);
cur = idx;
}
_nodes[cur].output = true;
}
void Build()
{
std::queue<uint32> bfs;
for (auto const& kv : _nodes[0].next)
{
_nodes[kv.second].fail = 0;
bfs.push(kv.second);
}
while (!bfs.empty())
{
uint32 u = bfs.front();
bfs.pop();
for (auto const& kv : _nodes[u].next)
{
CharT c = kv.first;
uint32 v = kv.second;
uint32 f = _nodes[u].fail;
while (f != 0 && _nodes[f].next.find(c) == _nodes[f].next.end())
f = _nodes[f].fail;
auto it = _nodes[f].next.find(c);
_nodes[v].fail = (it != _nodes[f].next.end() && it->second != v) ? it->second : 0;
if (_nodes[_nodes[v].fail].output)
_nodes[v].output = true;
bfs.push(v);
}
}
}
[[nodiscard]] bool ContainsAny(StringViewType text) const
{
if (_nodes.size() <= 1)
return false;
uint32 cur = 0;
for (CharT c : text)
{
while (cur != 0 && _nodes[cur].next.find(c) == _nodes[cur].next.end())
cur = _nodes[cur].fail;
auto it = _nodes[cur].next.find(c);
if (it != _nodes[cur].next.end())
cur = it->second;
if (_nodes[cur].output)
return true;
}
return false;
}
[[nodiscard]] bool Empty() const { return _nodes.size() <= 1; }
void Clear()
{
_nodes.clear();
_nodes.emplace_back();
}
private:
struct Node
{
std::unordered_map<CharT, uint32> next;
uint32 fail = 0;
bool output = false;
};
std::vector<Node> _nodes;
};
}
//! namespace Acore
#endif // AhoCorasick_h__

View File

@@ -4381,6 +4381,21 @@ PreserveCustomChannels = 0
PreserveCustomChannelDuration = 14
#
# ChatFilter
# Description: Blocks chat messages when they contain any entry from the `chat_filter`
# table (substring, case-insensitive).
# Manage entries with .chatfilter add / remove / list and .reload chat_filter.
# Blizzlike is only whispers are enabled.
# Default: 0 - (Disbaled)
# 1 - (Enabled)
#
ChatFilter.Whisper = 1
ChatFilter.Say = 0
ChatFilter.Yell = 0
ChatFilter.Emote = 0
#
###################################################################################################

View File

@@ -609,6 +609,12 @@ void CharacterDatabaseConnection::DoPrepareStatements()
PrepareStatement(CHAR_INS_RESERVED_PLAYER_NAME, "INSERT IGNORE INTO reserved_name (name) VALUES (?)", CONNECTION_ASYNC);
PrepareStatement(CHAR_INS_PROFANITY_PLAYER_NAME, "INSERT IGNORE INTO profanity_name (name) VALUES (?)", CONNECTION_ASYNC);
// Chat filter
PrepareStatement(CHAR_SEL_CHAT_FILTER, "SELECT ID, Word FROM chat_filter ORDER BY ID", CONNECTION_SYNCH);
PrepareStatement(CHAR_SEL_CHAT_FILTER_WORD, "SELECT ID FROM chat_filter WHERE Word = ?", CONNECTION_SYNCH);
PrepareStatement(CHAR_INS_CHAT_FILTER_WORD, "INSERT INTO chat_filter (Word) VALUES (?)", CONNECTION_SYNCH);
PrepareStatement(CHAR_DEL_CHAT_FILTER_WORD, "DELETE FROM chat_filter WHERE Word = ?", CONNECTION_SYNCH);
// Character settings
PrepareStatement(CHAR_SEL_CHAR_SETTINGS, "SELECT source, data FROM character_settings WHERE guid = ?", CONNECTION_BOTH);
PrepareStatement(CHAR_REP_CHAR_SETTINGS, "REPLACE INTO character_settings (guid, source, data) VALUES (?, ?, ?)", CONNECTION_ASYNC);

View File

@@ -523,6 +523,11 @@ enum CharacterDatabaseStatements : uint32
CHAR_INS_RESERVED_PLAYER_NAME,
CHAR_INS_PROFANITY_PLAYER_NAME,
CHAR_SEL_CHAT_FILTER,
CHAR_SEL_CHAT_FILTER_WORD,
CHAR_INS_CHAT_FILTER_WORD,
CHAR_DEL_CHAT_FILTER_WORD,
CHAR_SEL_CHAR_SETTINGS,
CHAR_REP_CHAR_SETTINGS,
CHAR_DEL_CHAR_SETTINGS,

View File

@@ -684,6 +684,9 @@ enum RBACPermissions
RBAC_PERM_COMMAND_DEBUG_INFO = 920,
RBAC_PERM_COMMAND_DEBUG_COSMETIC = 921,
RBAC_PERM_COMMAND_PET_RENAME = 922,
RBAC_PERM_COMMAND_CHATFILTER_LIST = 923,
RBAC_PERM_COMMAND_CHATFILTER_ADD = 924,
RBAC_PERM_COMMAND_CHATFILTER_REMOVE = 925,
// custom permissions 1000+
RBAC_PERM_MAX
};

View File

@@ -9390,6 +9390,12 @@ void Player::Say(std::string_view text, Language language, WorldObject const* /*
if (!sScriptMgr->OnPlayerCanUseChat(this, CHAT_MSG_SAY, language, _text))
return;
if (sWorld->getBoolConfig(CONFIG_CHAT_FILTER_SAY) && IsChatFiltered(text))
{
ChatHandler(GetSession()).SendSysMessage(LANG_CHATFILTER_SAY);
return;
}
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_SAY, language, this, this, _text);
@@ -9412,6 +9418,12 @@ void Player::Yell(std::string_view text, Language language, WorldObject const* /
if (!sScriptMgr->OnPlayerCanUseChat(this, CHAT_MSG_YELL, language, _text))
return;
if (sWorld->getBoolConfig(CONFIG_CHAT_FILTER_YELL) && IsChatFiltered(text))
{
ChatHandler(GetSession()).SendSysMessage(LANG_CHATFILTER_YELL);
return;
}
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_YELL, language, this, this, _text);
@@ -9434,6 +9446,12 @@ void Player::TextEmote(std::string_view text, WorldObject const* /*= nullptr*/,
if (!sScriptMgr->OnPlayerCanUseChat(this, CHAT_MSG_EMOTE, LANG_UNIVERSAL, _text))
return;
if (sWorld->getBoolConfig(CONFIG_CHAT_FILTER_EMOTE) && IsChatFiltered(text))
{
ChatHandler(GetSession()).SendSysMessage(LANG_CHATFILTER_EMOTE);
return;
}
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_EMOTE, LANG_UNIVERSAL, this, this, _text);
@@ -9463,15 +9481,24 @@ void Player::Whisper(std::string_view text, Language language, Player* target, b
if (!sScriptMgr->OnPlayerCanUseChat(this, CHAT_MSG_WHISPER, language, _text, target))
return;
bool isFiltered = sWorld->getBoolConfig(CONFIG_CHAT_FILTER_WHISPER) && IsChatFiltered(text);
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, language, this, this, _text);
target->SendDirectMessage(&data);
if (!isFiltered || isAddonMessage)
{
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, language, this, this, _text);
target->SendDirectMessage(&data);
}
// rest stuff shouldn't happen in case of addon message
if (isAddonMessage)
return;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER_INFORM, Language(language), target, target, _text);
ChatMsg msgType = CHAT_MSG_WHISPER_INFORM;
if (isFiltered)
msgType = CHAT_MSG_FILTERED;
ChatHandler::BuildChatPacket(data, msgType, Language(language), target, target, _text);
SendDirectMessage(&data);
if (!isAcceptWhispers() && !IsGameMaster() && !target->IsGameMaster())
@@ -9512,6 +9539,11 @@ void Player::Whisper(uint32 textId, Player* target, bool isBossWhisper)
target->SendDirectMessage(&data);
}
bool Player::IsChatFiltered(std::string_view text)
{
return sObjectMgr->IsChatFiltered(text);
}
void Player::PetSpellInitialize()
{
Pet* pet = GetPet();

View File

@@ -1245,6 +1245,8 @@ public:
/// Handles whispers from Addons and players based on sender, receiver's guid and language.
void Whisper(std::string_view text, Language language, Player* receiver, bool = false) override;
void Whisper(uint32 textId, Player* target, bool isBossWhisper = false) override;
/// Returns true if @p text contains any word from the `chat_filter` DB table (substring, case-insensitive).
static bool IsChatFiltered(std::string_view text);
/*********************************************************/
/*** STORAGE SYSTEM ***/

View File

@@ -9197,6 +9197,73 @@ void ObjectMgr::AddProfanityPlayerName(std::string const& name)
}
}
void ObjectMgr::LoadChatFilter()
{
uint32 oldMSTime = getMSTime();
_chatFilterAutomaton.reset(); // need for reload case
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHAT_FILTER);
PreparedQueryResult result = CharacterDatabase.Query(stmt);
if (!result)
{
LOG_WARN("server.loading", ">> Loaded 0 chat filter words. DB table `chat_filter` is empty!");
LOG_INFO("server.loading", " ");
return;
}
auto automaton = std::make_unique<Acore::AhoCorasick<wchar_t>>();
uint32 count = 0;
do
{
Field* fields = result->Fetch();
std::string word = fields[1].Get<std::string>();
if (word.empty())
continue;
std::wstring wstr;
if (!Utf8toWStr(word, wstr))
{
LOG_ERROR("sql.sql", "Table `chat_filter` has invalid word: {}", word);
continue;
}
wstrToLower(wstr);
if (wstr.empty())
continue;
automaton->Insert(wstr);
++count;
} while (result->NextRow());
if (count > 0)
{
automaton->Build();
_chatFilterAutomaton = std::move(automaton);
}
LOG_INFO("server.loading", ">> Loaded {} chat filter words in {} ms", count, GetMSTimeDiffToNow(oldMSTime));
LOG_INFO("server.loading", " ");
}
bool ObjectMgr::IsChatFiltered(std::string_view text) const
{
if (!_chatFilterAutomaton || text.empty())
return false;
std::wstring wtext;
if (!Utf8toWStr(text, wtext))
return false;
wstrToLower(wtext);
return _chatFilterAutomaton->ContainsAny(wtext);
}
enum LanguageType
{
LT_BASIC_LATIN = 0x0000,

View File

@@ -18,6 +18,7 @@
#ifndef _OBJECTMGR_H
#define _OBJECTMGR_H
#include "AhoCorasick.h"
#include "Bag.h"
#include "ConditionMgr.h"
#include "Creature.h"
@@ -38,6 +39,7 @@
#include <functional>
#include <limits>
#include <map>
#include <memory>
#include <string>
class Item;
@@ -1418,6 +1420,10 @@ public:
[[nodiscard]] bool IsProfanityName(std::string_view name) const;
void AddProfanityPlayerName(std::string const& name);
// chat filter (substring chat content filter)
void LoadChatFilter();
[[nodiscard]] bool IsChatFiltered(std::string_view text) const;
// name with valid structure and symbols
static uint8 CheckPlayerName(std::string_view name, bool create = false);
static PetNameInvalidReason CheckPetName(std::string_view name);
@@ -1587,6 +1593,9 @@ private:
typedef std::set<std::wstring> ProfanityNamesContainer;
ProfanityNamesContainer _profanityNamesStore;
//chat filter (Aho-Corasick automaton; matches any banned word as substring of input)
std::unique_ptr<Acore::AhoCorasick<wchar_t>> _chatFilterAutomaton;
GameTeleContainer _gameTeleStore;
ScriptNameContainer _scriptNamesStore;

View File

@@ -1229,7 +1229,12 @@ enum AcoreStrings
LANG_MAIL_RETURN_ALREADY_RETURNED = 5142,
LANG_MAIL_RETURN_HOOK_BLOCKED = 5143,
// Room for more strings 5144-9999
// Chat filter
LANG_CHATFILTER_EMOTE = 5144,
LANG_CHATFILTER_SAY = 5145,
LANG_CHATFILTER_YELL = 5146,
// Room for more strings 5147-9999
// Level requirement notifications
LANG_SAY_REQ = 6604,

View File

@@ -764,6 +764,9 @@ void World::SetInitialWorldSettings()
sObjectMgr->LoadProfanityNamesFromDB();
sObjectMgr->LoadProfanityNamesFromDBC(); // Needs to be after LoadProfanityNamesFromDB()
LOG_INFO("server.loading", "Loading Chat Filter...");
sObjectMgr->LoadChatFilter();
LOG_INFO("server.loading", "Loading GameObjects for Quests...");
sObjectMgr->LoadGameObjectForQuests();

View File

@@ -198,6 +198,11 @@ void WorldConfig::BuildConfigCache()
SetConfigValue<uint32>(CONFIG_STRICT_CHANNEL_NAMES, "StrictChannelNames", 0);
SetConfigValue<uint32>(CONFIG_STRICT_PET_NAMES, "StrictPetNames", 0);
SetConfigValue<bool>(CONFIG_CHAT_FILTER_WHISPER, "ChatFilter.Whisper", true);
SetConfigValue<bool>(CONFIG_CHAT_FILTER_SAY, "ChatFilter.Say", false);
SetConfigValue<bool>(CONFIG_CHAT_FILTER_YELL, "ChatFilter.Yell", false);
SetConfigValue<bool>(CONFIG_CHAT_FILTER_EMOTE, "ChatFilter.Emote", false);
SetConfigValue<bool>(CONFIG_ALLOW_TWO_SIDE_ACCOUNTS, "AllowTwoSide.Accounts", true);
SetConfigValue<bool>(CONFIG_ALLOW_TWO_SIDE_INTERACTION_CALENDAR, "AllowTwoSide.Interaction.Calendar", false);
SetConfigValue<bool>(CONFIG_ALLOW_TWO_SIDE_INTERACTION_CHAT, "AllowTwoSide.Interaction.Chat", false);

View File

@@ -139,6 +139,10 @@ enum ServerConfigs
CONFIG_OBJECT_QUEST_MARKERS,
CONFIG_STRICT_NAMES_RESERVED,
CONFIG_STRICT_NAMES_PROFANITY,
CONFIG_CHAT_FILTER_WHISPER,
CONFIG_CHAT_FILTER_SAY,
CONFIG_CHAT_FILTER_YELL,
CONFIG_CHAT_FILTER_EMOTE,
CONFIG_ALLOWS_RANK_MOD_FOR_PET_HEALTH,
CONFIG_MUNCHING_BLIZZLIKE,
CONFIG_ENABLE_DAZE,

View File

@@ -0,0 +1,127 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Chat.h"
#include "CommandScript.h"
#include "DatabaseEnv.h"
#include "ObjectMgr.h"
using namespace Acore::ChatCommands;
class chatfilter_commandscript : public CommandScript
{
public:
chatfilter_commandscript() : CommandScript("chatfilter_commandscript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable chatfilterCommandTable =
{
{ "list", HandleChatFilterListCommand, rbac::RBAC_PERM_COMMAND_CHATFILTER_LIST, Console::Yes },
{ "add", HandleChatFilterAddCommand, rbac::RBAC_PERM_COMMAND_CHATFILTER_ADD, Console::Yes },
{ "remove", HandleChatFilterRemoveCommand, rbac::RBAC_PERM_COMMAND_CHATFILTER_REMOVE, Console::Yes }
};
static ChatCommandTable commandTable =
{
{ "chatfilter", chatfilterCommandTable }
};
return commandTable;
}
static bool HandleChatFilterListCommand(ChatHandler* handler)
{
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHAT_FILTER);
PreparedQueryResult result = CharacterDatabase.Query(stmt);
if (!result)
{
handler->SendSysMessage("No chat filter words found.");
return true;
}
handler->SendSysMessage("Chat filter words:");
uint32 count = 0;
do
{
Field* fields = result->Fetch();
uint32 id = fields[0].Get<uint32>();
std::string word = fields[1].Get<std::string>();
handler->PSendSysMessage(" ID: {} | Word: {}", id, word);
++count;
} while (result->NextRow());
handler->PSendSysMessage("{} chat filter word(s) total.", count);
return true;
}
static bool HandleChatFilterAddCommand(ChatHandler* handler, Tail word)
{
if (word.empty())
return false;
std::string text(word);
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHAT_FILTER_WORD);
stmt->SetData(0, text);
if (CharacterDatabase.Query(stmt))
{
handler->SendErrorMessage("Chat filter word \"{}\" already exists.", text);
return true;
}
stmt = CharacterDatabase.GetPreparedStatement(CHAR_INS_CHAT_FILTER_WORD);
stmt->SetData(0, text);
CharacterDatabase.DirectExecute(stmt);
sObjectMgr->LoadChatFilter();
handler->PSendSysMessage("Chat filter word \"{}\" added.", text);
return true;
}
static bool HandleChatFilterRemoveCommand(ChatHandler* handler, Tail word)
{
if (word.empty())
return false;
std::string text(word);
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHAT_FILTER_WORD);
stmt->SetData(0, text);
if (!CharacterDatabase.Query(stmt))
{
handler->SendErrorMessage("Chat filter word \"{}\" not found.", text);
return true;
}
stmt = CharacterDatabase.GetPreparedStatement(CHAR_DEL_CHAT_FILTER_WORD);
stmt->SetData(0, text);
CharacterDatabase.DirectExecute(stmt);
sObjectMgr->LoadChatFilter();
handler->PSendSysMessage("Chat filter word \"{}\" removed.", text);
return true;
}
};
void AddSC_chatfilter_commandscript()
{
new chatfilter_commandscript();
}

View File

@@ -146,6 +146,7 @@ public:
{ "reference_loot_template", HandleReloadLootTemplatesReferenceCommand, rbac::RBAC_PERM_COMMAND_RELOAD_REFERENCE_LOOT_TEMPLATE, Console::Yes },
{ "reserved_name", HandleReloadReservedNameCommand, rbac::RBAC_PERM_COMMAND_RELOAD_RESERVED_NAME, Console::Yes },
{ "profanity_name", HandleReloadProfanityNameCommand, rbac::RBAC_PERM_COMMAND_RELOAD, Console::Yes },
{ "chat_filter", HandleReloadChatFilterCommand, rbac::RBAC_PERM_COMMAND_RELOAD, Console::Yes },
{ "reputation_reward_rate", HandleReloadReputationRewardRateCommand, rbac::RBAC_PERM_COMMAND_RELOAD_REPUTATION_REWARD_RATE, Console::Yes },
{ "reputation_spillover_template", HandleReloadReputationRewardRateCommand, rbac::RBAC_PERM_COMMAND_RELOAD_SPILLOVER_TEMPLATE, Console::Yes },
{ "skill_discovery_template", HandleReloadSkillDiscoveryTemplateCommand, rbac::RBAC_PERM_COMMAND_RELOAD_SKILL_DISCOVERY_TEMPLATE, Console::Yes },
@@ -210,6 +211,7 @@ public:
HandleReloadCommandCommand(handler);
HandleReloadReservedNameCommand(handler);
HandleReloadProfanityNameCommand(handler);
HandleReloadChatFilterCommand(handler);
HandleReloadAcoreStringCommand(handler);
HandleReloadGameTeleCommand(handler);
HandleReloadCreatureMovementOverrideCommand(handler);
@@ -832,6 +834,14 @@ public:
return true;
}
static bool HandleReloadChatFilterCommand(ChatHandler* handler)
{
LOG_INFO("server.loading", "Reloading Chat Filter!");
sObjectMgr->LoadChatFilter();
handler->SendGlobalGMSysMessage("Chat Filter reloaded.");
return true;
}
static bool HandleReloadReputationRewardRateCommand(ChatHandler* handler)
{
LOG_INFO("server.loading", "Reloading `reputation_reward_rate` Table!" );

View File

@@ -30,6 +30,7 @@ void AddSC_debug_commandscript();
void AddSC_deserter_commandscript();
void AddSC_disable_commandscript();
void AddSC_event_commandscript();
void AddSC_chatfilter_commandscript();
void AddSC_gear_commandscript();
void AddSC_gm_commandscript();
void AddSC_go_commandscript();
@@ -88,6 +89,7 @@ void AddCommandsScripts()
AddSC_deserter_commandscript();
AddSC_disable_commandscript();
AddSC_event_commandscript();
AddSC_chatfilter_commandscript();
AddSC_gear_commandscript();
AddSC_gm_commandscript();
AddSC_go_commandscript();