From d5a8e56e9059acf2757eecdb3cbb9e7c4b7c8244 Mon Sep 17 00:00:00 2001 From: ebcq Date: Tue, 19 May 2026 19:49:43 +0200 Subject: [PATCH 1/2] feat: moderation (anti spam, anti cross-posting) --- CMakeLists.txt | 18 +++- src/commands/rule_cmd.cpp | 2 +- src/globals/globals.h | 1 + src/main.cpp | 7 +- src/utils/moderation/moderation.cpp | 129 ++++++++++++++++++++++++++++ src/utils/moderation/moderation.h | 59 +++++++++++++ 6 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/utils/moderation/moderation.cpp create mode 100644 src/utils/moderation/moderation.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 10a8bfe..0ed48a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,20 @@ -cmake_minimum_required(VERSION 3.3) +cmake_minimum_required(VERSION 3.25) project(bot LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(dpp CONFIG REQUIRED) + +# find_package(dpp CONFIG REQUIRED) +include(FetchContent) + +FetchContent_Declare( + dpp + GIT_REPOSITORY https://github.com/brainboxdotcc/DPP.git + GIT_TAG v10.1.4 +) + +FetchContent_MakeAvailable(dpp) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/src/config.json DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/src/res DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) @@ -29,8 +39,10 @@ add_executable(bot # utils src/utils/suggestion/suggestion.cpp src/utils/suggestion/suggestion.h + src/utils/moderation/moderation.cpp + src/utils/moderation/moderation.h ) target_link_libraries(bot - PRIVATE dpp::dpp + PRIVATE dpp ) diff --git a/src/commands/rule_cmd.cpp b/src/commands/rule_cmd.cpp index 0af9778..ae66bcf 100644 --- a/src/commands/rule_cmd.cpp +++ b/src/commands/rule_cmd.cpp @@ -31,7 +31,7 @@ void cmd::ruleCommand(dpp::cluster& bot, const dpp::slashcommand_t& event) if (option.type != dpp::co_integer) return; // should never happen - const long index = std::get(option.value); + const auto index = std::get(option.value); if ((index < 1 || index > rules.size())) return event.reply(dpp::message("Rule number " + std::to_string(index) + " does not exist. Visit <#" + globals::channel::rulesId.str() + "> to see all available rules.").set_flags(dpp::m_ephemeral)); diff --git a/src/globals/globals.h b/src/globals/globals.h index ca5c242..a15e2d1 100644 --- a/src/globals/globals.h +++ b/src/globals/globals.h @@ -29,6 +29,7 @@ namespace globals namespace role { static constexpr dpp::snowflake staffId = 1130473404345110621; + static constexpr dpp::snowflake jailId = 1506351798900887582; } } diff --git a/src/main.cpp b/src/main.cpp index 2c6c04a..459c1b5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include "commands/commands.h" #include "utils/suggestion/suggestion.h" +#include "utils/moderation/moderation.h" using json = nlohmann::json; @@ -24,6 +25,7 @@ int main() json config = json::parse(configFile); dpp::cluster bot(config["token"], dpp::i_default_intents | dpp::i_message_content); + ModerationService moderationService(bot); bot.on_ready([&bot](const dpp::ready_t& event) { std::cout << "[!] Bot ready" << std::endl; @@ -62,7 +64,10 @@ int main() } }); - bot.on_message_create([&bot](const dpp::message_create_t& event) { + bot.on_message_create([&bot, &moderationService](const dpp::message_create_t& event) { + if (moderationService.handleMessage(event)) + return; + const dpp::channel* channel = dpp::find_channel(event.msg.channel_id); if (channel && channel->name == "suggestions") diff --git a/src/utils/moderation/moderation.cpp b/src/utils/moderation/moderation.cpp new file mode 100644 index 0000000..b905e4d --- /dev/null +++ b/src/utils/moderation/moderation.cpp @@ -0,0 +1,129 @@ +#include "moderation.h" +#include "../../globals/globals.h" + +ModerationService::ModerationService(dpp::cluster& bot) : bot(bot) {} + +void ModerationService::cleanupOldEntries(const std::chrono::steady_clock::time_point& now) +{ + const auto threshold = now - cleanupThreshold; + + for (auto it = state.begin(); it != state.end(); ) + { + if (it->second.lastPostTime < threshold) + it = state.erase(it); + else + ++it; + } +} + +std::string ModerationService::getMessageSignature(const dpp::message& msg) const +{ + std::string signature = msg.content; + + for (const auto& att : msg.attachments) + { + signature += '|'; + signature += att.filename; + signature += '|'; + signature += std::to_string(att.size); + } + + for (const auto& emb : msg.embeds) + { + signature += '|'; + signature += emb.title; + signature += '|'; + signature += emb.description; + signature += '|'; + signature += emb.url; + } + + return signature; +} + +bool ModerationService::handleMessage(const dpp::message_create_t& event) +{ + if (event.msg.author.is_bot()) + return false; + + if (event.msg.content.empty() && + event.msg.attachments.empty() && + event.msg.embeds.empty()) + return false; + + const auto now = std::chrono::steady_clock::now(); + const auto userId = event.msg.author.id; + const std::string signature = getMessageSignature(event.msg); + + bool warn = false; + bool jail = false; + std::vector toDelete; + dpp::snowflake targetChannel{}; + + { + std::scoped_lock lock(mtx); + cleanupOldEntries(now); + + auto& userState = state[userId]; + const bool sameMessage = (signature == userState.lastSignature); + const bool withinWindow = (now - userState.lastPostTime) <= crossPostWindow; + userState.lastPostTime = now; + + if (!sameMessage || !withinWindow) + { + userState.lastSignature = signature; + userState.messages.clear(); + } + + userState.messages.push_back({event.msg.channel_id, event.msg.id}); + + const std::size_t count = userState.messages.size(); + + if (count == 2) + { + warn = true; + targetChannel = event.msg.channel_id; + } + else if (count >= 3) + { + jail = true; + toDelete = std::move(userState.messages); + state.erase(userId); + } + } + + if (warn) + { + bot.message_delete(event.msg.id, targetChannel); + + bot.direct_message_create( + userId, + dpp::message(std::string(warningMsg)) + ); + + return true; + } + + if (jail) + { + for (const auto& m : toDelete) + { + bot.message_delete(m.messageId, m.channelId); + } + + bot.guild_member_add_role( + event.msg.guild_id, + userId, + globals::role::jailId + ); + + bot.direct_message_create( + userId, + dpp::message(std::string(jailMsg)) + ); + + return true; + } + + return false; +} diff --git a/src/utils/moderation/moderation.h b/src/utils/moderation/moderation.h new file mode 100644 index 0000000..0a96889 --- /dev/null +++ b/src/utils/moderation/moderation.h @@ -0,0 +1,59 @@ +#ifndef MODERATION_H +#define MODERATION_H + +#include +#include +#include +#include +#include +#include + +class ModerationService +{ +public: + explicit ModerationService(dpp::cluster& bot); + + /** + * @brief Analyzes the message for cross-posting/spam + * Duplicate content by same user in: + * - 2 channels: Delete the message and dm a warning + * - 3+ channels: Delete all messages and jail the user + * @param event message create event + * @return true if the message was deleted/handled, false otherwise + */ + bool handleMessage(const dpp::message_create_t& event); + +private: + static constexpr auto crossPostWindow = std::chrono::seconds{5}; + static constexpr auto cleanupThreshold = std::chrono::seconds{180}; + + static constexpr std::string_view warningMsg = + "You posted the same message on multiple channels. " + "Your message has been deleted, because we do not allow cross-posting."; + + static constexpr std::string_view jailMsg = + "You have been jailed as you posted the same message on multiple channels. " + "Please contact a staff member if you think this is a mistake."; + + struct PostedMessage + { + dpp::snowflake channelId{}; + dpp::snowflake messageId{}; + }; + + struct UserState + { + std::string lastSignature; + std::chrono::steady_clock::time_point lastPostTime{}; + std::vector messages; + }; + + std::string getMessageSignature(const dpp::message& msg) const; + void cleanupOldEntries(const std::chrono::steady_clock::time_point& now); + + dpp::cluster& bot; + std::mutex mtx; + std::unordered_map state; +}; + +#endif // MODERATION_H From 724e1f437364152ecaa8f927d2f4429df1fd3509 Mon Sep 17 00:00:00 2001 From: ebcq Date: Sun, 7 Jun 2026 21:58:20 +0200 Subject: [PATCH 2/2] feat: update moderation logic and improve message handling --- CMakeLists.txt | 3 +-- src/commands/rule_cmd.cpp | 3 ++- src/globals/globals.h | 1 + src/utils/moderation/moderation.cpp | 29 +++++++++++++---------------- src/utils/moderation/moderation.h | 6 +++--- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ed48a0..b478fb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,10 +2,9 @@ cmake_minimum_required(VERSION 3.25) project(bot LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# find_package(dpp CONFIG REQUIRED) include(FetchContent) FetchContent_Declare( diff --git a/src/commands/rule_cmd.cpp b/src/commands/rule_cmd.cpp index ae66bcf..b9b4253 100644 --- a/src/commands/rule_cmd.cpp +++ b/src/commands/rule_cmd.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -31,7 +32,7 @@ void cmd::ruleCommand(dpp::cluster& bot, const dpp::slashcommand_t& event) if (option.type != dpp::co_integer) return; // should never happen - const auto index = std::get(option.value); + const auto index = std::get(option.value); if ((index < 1 || index > rules.size())) return event.reply(dpp::message("Rule number " + std::to_string(index) + " does not exist. Visit <#" + globals::channel::rulesId.str() + "> to see all available rules.").set_flags(dpp::m_ephemeral)); diff --git a/src/globals/globals.h b/src/globals/globals.h index a15e2d1..c095d3b 100644 --- a/src/globals/globals.h +++ b/src/globals/globals.h @@ -19,6 +19,7 @@ namespace globals namespace channel { static constexpr dpp::snowflake rulesId = 1130464978860785705; + static constexpr dpp::snowflake jailId = 1513269975844917409; } namespace category diff --git a/src/utils/moderation/moderation.cpp b/src/utils/moderation/moderation.cpp index b905e4d..4cac98f 100644 --- a/src/utils/moderation/moderation.cpp +++ b/src/utils/moderation/moderation.cpp @@ -7,16 +7,13 @@ void ModerationService::cleanupOldEntries(const std::chrono::steady_clock::time_ { const auto threshold = now - cleanupThreshold; - for (auto it = state.begin(); it != state.end(); ) + std::erase_if(state, [&](const auto& entry) { - if (it->second.lastPostTime < threshold) - it = state.erase(it); - else - ++it; - } + return entry.second.lastPostTime < threshold; + }); } -std::string ModerationService::getMessageSignature(const dpp::message& msg) const +std::string ModerationService::makeMessageSignature(const dpp::message& msg) { std::string signature = msg.content; @@ -53,7 +50,7 @@ bool ModerationService::handleMessage(const dpp::message_create_t& event) const auto now = std::chrono::steady_clock::now(); const auto userId = event.msg.author.id; - const std::string signature = getMessageSignature(event.msg); + const std::string signature = makeMessageSignature(event.msg); bool warn = false; bool jail = false; @@ -96,10 +93,10 @@ bool ModerationService::handleMessage(const dpp::message_create_t& event) { bot.message_delete(event.msg.id, targetChannel); - bot.direct_message_create( - userId, - dpp::message(std::string(warningMsg)) - ); + dpp::message warnMessage(targetChannel, + dpp::utility::user_mention(userId) + " " + std::string(warningMsg)); + warnMessage.set_allowed_mentions(false, false, false, false, {userId}, {}); + bot.message_create(warnMessage); return true; } @@ -117,10 +114,10 @@ bool ModerationService::handleMessage(const dpp::message_create_t& event) globals::role::jailId ); - bot.direct_message_create( - userId, - dpp::message(std::string(jailMsg)) - ); + dpp::message jailMessage(globals::channel::jailId, + dpp::utility::user_mention(userId) + " " + std::string(jailMsg)); + jailMessage.set_allowed_mentions(false, false, false, false, {userId}, {}); + bot.message_create(jailMessage); return true; } diff --git a/src/utils/moderation/moderation.h b/src/utils/moderation/moderation.h index 0a96889..6eff698 100644 --- a/src/utils/moderation/moderation.h +++ b/src/utils/moderation/moderation.h @@ -16,7 +16,7 @@ class ModerationService /** * @brief Analyzes the message for cross-posting/spam * Duplicate content by same user in: - * - 2 channels: Delete the message and dm a warning + * - 2 channels: Delete the message and send a warning * - 3+ channels: Delete all messages and jail the user * @param event message create event * @return true if the message was deleted/handled, false otherwise @@ -24,7 +24,7 @@ class ModerationService bool handleMessage(const dpp::message_create_t& event); private: - static constexpr auto crossPostWindow = std::chrono::seconds{5}; + static constexpr auto crossPostWindow = std::chrono::seconds{10}; static constexpr auto cleanupThreshold = std::chrono::seconds{180}; static constexpr std::string_view warningMsg = @@ -48,7 +48,7 @@ class ModerationService std::vector messages; }; - std::string getMessageSignature(const dpp::message& msg) const; + static std::string makeMessageSignature(const dpp::message& msg); void cleanupOldEntries(const std::chrono::steady_clock::time_point& now); dpp::cluster& bot;