diff --git a/CMakeLists.txt b/CMakeLists.txt index 10a8bfe..b478fb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,19 @@ -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 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -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 +38,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..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 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..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 @@ -29,6 +30,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..4cac98f --- /dev/null +++ b/src/utils/moderation/moderation.cpp @@ -0,0 +1,126 @@ +#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; + + std::erase_if(state, [&](const auto& entry) + { + return entry.second.lastPostTime < threshold; + }); +} + +std::string ModerationService::makeMessageSignature(const dpp::message& msg) +{ + 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 = makeMessageSignature(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); + + 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; + } + + 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 + ); + + 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; + } + + return false; +} diff --git a/src/utils/moderation/moderation.h b/src/utils/moderation/moderation.h new file mode 100644 index 0000000..6eff698 --- /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 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 + */ + bool handleMessage(const dpp::message_create_t& event); + +private: + static constexpr auto crossPostWindow = std::chrono::seconds{10}; + 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; + }; + + static std::string makeMessageSignature(const dpp::message& msg); + void cleanupOldEntries(const std::chrono::steady_clock::time_point& now); + + dpp::cluster& bot; + std::mutex mtx; + std::unordered_map state; +}; + +#endif // MODERATION_H