From 8b8ba243d8f7e909efce7083060289f48795cd68 Mon Sep 17 00:00:00 2001
From: Kitzunu <24550914+Kitzunu@users.noreply.github.com>
Date: Mon, 1 Jun 2026 22:26:28 +0200
Subject: [PATCH] feat(Core/Chat): Add chat filter with Aho-Corasick matching
(#26051)
---
.../rev_1780327604603266200.sql | 12 ++
.../rev_1780325063053000000.sql | 12 ++
.../rev_1780326174734000000.sql | 14 ++
src/common/Utilities/AhoCorasick.h | 157 ++++++++++++++++++
.../apps/worldserver/worldserver.conf.dist | 15 ++
.../Implementation/CharacterDatabase.cpp | 6 +
.../Implementation/CharacterDatabase.h | 5 +
src/server/game/Accounts/RBAC.h | 3 +
src/server/game/Entities/Player/Player.cpp | 38 ++++-
src/server/game/Entities/Player/Player.h | 2 +
src/server/game/Globals/ObjectMgr.cpp | 67 ++++++++
src/server/game/Globals/ObjectMgr.h | 9 +
src/server/game/Miscellaneous/Language.h | 7 +-
src/server/game/World/World.cpp | 3 +
src/server/game/World/WorldConfig.cpp | 5 +
src/server/game/World/WorldConfig.h | 4 +
src/server/scripts/Commands/cs_chatfilter.cpp | 127 ++++++++++++++
src/server/scripts/Commands/cs_reload.cpp | 10 ++
.../scripts/Commands/cs_script_loader.cpp | 2 +
19 files changed, 494 insertions(+), 4 deletions(-)
create mode 100644 data/sql/updates/pending_db_auth/rev_1780327604603266200.sql
create mode 100644 data/sql/updates/pending_db_characters/rev_1780325063053000000.sql
create mode 100644 data/sql/updates/pending_db_world/rev_1780326174734000000.sql
create mode 100644 src/common/Utilities/AhoCorasick.h
create mode 100644 src/server/scripts/Commands/cs_chatfilter.cpp
diff --git a/data/sql/updates/pending_db_auth/rev_1780327604603266200.sql b/data/sql/updates/pending_db_auth/rev_1780327604603266200.sql
new file mode 100644
index 0000000000..5bd98f5661
--- /dev/null
+++ b/data/sql/updates/pending_db_auth/rev_1780327604603266200.sql
@@ -0,0 +1,12 @@
+--
+DELETE FROM `rbac_permissions` WHERE `id` IN (923, 924, 925);
+INSERT INTO `rbac_permissions` (`id`, `name`) VALUES
+(923, 'Command: chatfilter list'),
+(924, 'Command: chatfilter add'),
+(925, 'Command: chatfilter remove');
+
+DELETE FROM `rbac_linked_permissions` WHERE `id` = 197 AND `linkedId` IN (923, 924, 925);
+INSERT INTO `rbac_linked_permissions` (`id`, `linkedId`) VALUES
+(197, 923),
+(197, 924),
+(197, 925);
diff --git a/data/sql/updates/pending_db_characters/rev_1780325063053000000.sql b/data/sql/updates/pending_db_characters/rev_1780325063053000000.sql
new file mode 100644
index 0000000000..f20182ba62
--- /dev/null
+++ b/data/sql/updates/pending_db_characters/rev_1780325063053000000.sql
@@ -0,0 +1,12 @@
+-- Add `chat_filter` table for chat content filtering.
+DROP TABLE IF EXISTS `chat_filter`;
+CREATE TABLE `chat_filter` (
+ `ID` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `Word` VARCHAR(255) NOT NULL,
+ PRIMARY KEY (`ID`) USING BTREE
+)
+CHARSET = utf8mb4
+COLLATE = utf8mb4_unicode_ci
+ENGINE = InnoDB
+ROW_FORMAT = DEFAULT
+;
diff --git a/data/sql/updates/pending_db_world/rev_1780326174734000000.sql b/data/sql/updates/pending_db_world/rev_1780326174734000000.sql
new file mode 100644
index 0000000000..5a9e7b7d58
--- /dev/null
+++ b/data/sql/updates/pending_db_world/rev_1780326174734000000.sql
@@ -0,0 +1,14 @@
+-- Add chatfilter and reload chat_filter commands.
+DELETE FROM `command` WHERE `name` IN ('chatfilter', 'chatfilter list', 'chatfilter add', 'chatfilter remove', 'reload chat_filter');
+INSERT INTO `command` (`name`, `security`, `help`) VALUES
+('chatfilter', 2, 'Syntax: .chatfilter $subcommand\nType .chatfilter to see a list of subcommands or .help chatfilter $subcommand to see info on subcommands.'),
+('chatfilter list', 2, 'Syntax: .chatfilter list\nList all entries from the `chat_filter` table.'),
+('chatfilter add', 3, 'Syntax: .chatfilter add $word\nAdd $word to the `chat_filter` table and reload the chat filter.'),
+('chatfilter remove', 3, 'Syntax: .chatfilter remove $word\nRemove $word from the `chat_filter` table and reload the chat filter.'),
+('reload chat_filter', 3, 'Syntax: .reload chat_filter\n\nReload the `chat_filter` table.');
+
+DELETE FROM `acore_string` WHERE `entry` IN (5144, 5145, 5146);
+INSERT INTO `acore_string` (`entry`, `content_default`, `locale_koKR`, `locale_frFR`, `locale_deDE`, `locale_zhCN`, `locale_zhTW`, `locale_esES`, `locale_esMX`, `locale_ruRU`) VALUES
+(5144, '|cffff0000Unable to send emote because your message contained reserved words.|r', '|cffff0000감정표현에 예약된 단어가 포함되어 있어 전송할 수 없습니다.|r', '|cffff0000Impossible d''envoyer l''emote car votre message contient des mots réservés.|r', '|cffff0000Emote konnte nicht gesendet werden, da Ihre Nachricht reservierte Wörter enthielt.|r', '|cffff0000无法发送表情,因为您的消息包含保留字。|r', '|cffff0000無法傳送表情,因為您的訊息包含保留字。|r', '|cffff0000No se puede enviar el gesto porque tu mensaje contenía palabras reservadas.|r', '|cffff0000No se puede enviar el gesto porque tu mensaje contenía palabras reservadas.|r', '|cffff0000Невозможно отправить эмоцию, так как ваше сообщение содержит зарезервированные слова.|r'),
+(5145, '|cffff0000Unable to send message because your message contained reserved words.|r', '|cffff0000메시지에 예약된 단어가 포함되어 있어 전송할 수 없습니다.|r', '|cffff0000Impossible d''envoyer le message car votre message contient des mots réservés.|r', '|cffff0000Nachricht konnte nicht gesendet werden, da Ihre Nachricht reservierte Wörter enthielt.|r', '|cffff0000无法发送消息,因为您的消息包含保留字。|r', '|cffff0000無法傳送訊息,因為您的訊息包含保留字。|r', '|cffff0000No se puede enviar el mensaje porque tu mensaje contenía palabras reservadas.|r', '|cffff0000No se puede enviar el mensaje porque tu mensaje contenía palabras reservadas.|r', '|cffff0000Невозможно отправить сообщение, так как ваше сообщение содержит зарезервированные слова.|r'),
+(5146, '|cffff0000Unable to send yell because your message contained reserved words.|r', '|cffff0000외치기에 예약된 단어가 포함되어 있어 전송할 수 없습니다.|r', '|cffff0000Impossible d''envoyer le cri car votre message contient des mots réservés.|r', '|cffff0000Ruf konnte nicht gesendet werden, da Ihre Nachricht reservierte Wörter enthielt.|r', '|cffff0000无法发送大喊,因为您的消息包含保留字。|r', '|cffff0000無法傳送大喊,因為您的訊息包含保留字。|r', '|cffff0000No se puede enviar el grito porque tu mensaje contenía palabras reservadas.|r', '|cffff0000No se puede enviar el grito porque tu mensaje contenía palabras reservadas.|r', '|cffff0000Невозможно отправить крик, так как ваше сообщение содержит зарезервированные слова.|r');
diff --git a/src/common/Utilities/AhoCorasick.h b/src/common/Utilities/AhoCorasick.h
new file mode 100644
index 0000000000..eb6586606a
--- /dev/null
+++ b/src/common/Utilities/AhoCorasick.h
@@ -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 .
+ */
+
+#ifndef AhoCorasick_h__
+#define AhoCorasick_h__
+
+#include "Define.h"
+#include
+#include
+#include
+#include
+#include
+
+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 a;
+ * a.Insert(L"foo");
+ * a.Insert(L"bar");
+ * a.Build();
+ * bool hit = a.ContainsAny(L"the bar is open"); // -> true
+ */
+ template
+ class AhoCorasick
+ {
+ public:
+ using StringType = std::basic_string;
+ using StringViewType = std::basic_string_view;
+
+ 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(_nodes.size());
+ _nodes.emplace_back();
+ _nodes[cur].next.emplace(c, idx);
+ cur = idx;
+ }
+ _nodes[cur].output = true;
+ }
+
+ void Build()
+ {
+ std::queue 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 next;
+ uint32 fail = 0;
+ bool output = false;
+ };
+
+ std::vector _nodes;
+ };
+}
+//! namespace Acore
+
+#endif // AhoCorasick_h__
diff --git a/src/server/apps/worldserver/worldserver.conf.dist b/src/server/apps/worldserver/worldserver.conf.dist
index 42051b97cf..aa9c0eda38 100644
--- a/src/server/apps/worldserver/worldserver.conf.dist
+++ b/src/server/apps/worldserver/worldserver.conf.dist
@@ -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
+
#
###################################################################################################
diff --git a/src/server/database/Database/Implementation/CharacterDatabase.cpp b/src/server/database/Database/Implementation/CharacterDatabase.cpp
index de3cdeb37c..ca1bfa5e3e 100644
--- a/src/server/database/Database/Implementation/CharacterDatabase.cpp
+++ b/src/server/database/Database/Implementation/CharacterDatabase.cpp
@@ -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);
diff --git a/src/server/database/Database/Implementation/CharacterDatabase.h b/src/server/database/Database/Implementation/CharacterDatabase.h
index 313a87dd5c..666be52551 100644
--- a/src/server/database/Database/Implementation/CharacterDatabase.h
+++ b/src/server/database/Database/Implementation/CharacterDatabase.h
@@ -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,
diff --git a/src/server/game/Accounts/RBAC.h b/src/server/game/Accounts/RBAC.h
index 076c1e5d94..2989bbf5e0 100644
--- a/src/server/game/Accounts/RBAC.h
+++ b/src/server/game/Accounts/RBAC.h
@@ -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
};
diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp
index f04bc00edc..902fb413d4 100644
--- a/src/server/game/Entities/Player/Player.cpp
+++ b/src/server/game/Entities/Player/Player.cpp
@@ -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();
diff --git a/src/server/game/Entities/Player/Player.h b/src/server/game/Entities/Player/Player.h
index 71831c18bc..1187ba7ab8 100644
--- a/src/server/game/Entities/Player/Player.h
+++ b/src/server/game/Entities/Player/Player.h
@@ -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 ***/
diff --git a/src/server/game/Globals/ObjectMgr.cpp b/src/server/game/Globals/ObjectMgr.cpp
index 2590926cc6..b934ffa5cf 100644
--- a/src/server/game/Globals/ObjectMgr.cpp
+++ b/src/server/game/Globals/ObjectMgr.cpp
@@ -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>();
+ uint32 count = 0;
+
+ do
+ {
+ Field* fields = result->Fetch();
+ std::string word = fields[1].Get();
+
+ 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,
diff --git a/src/server/game/Globals/ObjectMgr.h b/src/server/game/Globals/ObjectMgr.h
index 24f44441e9..8a89fc3fcd 100644
--- a/src/server/game/Globals/ObjectMgr.h
+++ b/src/server/game/Globals/ObjectMgr.h
@@ -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
#include
#include