diff options
| author | ilotterytea <iltsu@alright.party> | 2024-05-18 14:48:12 +0500 |
|---|---|---|
| committer | ilotterytea <iltsu@alright.party> | 2024-05-18 14:48:12 +0500 |
| commit | d1793df1eda463b10107d41785ad1d7f055ed476 (patch) | |
| tree | fd3e41c3b4a05924748ae4b762e1ae55a0bc815c /bot | |
| parent | d7a2de17e9b7931f68b5b4079b1c36866a19d343 (diff) | |
upd: moved the bot part to a relative subfolder
Diffstat (limited to 'bot')
47 files changed, 3427 insertions, 0 deletions
diff --git a/bot/src/api/twitch/helix_client.cpp b/bot/src/api/twitch/helix_client.cpp new file mode 100644 index 0000000..04d630b --- /dev/null +++ b/bot/src/api/twitch/helix_client.cpp @@ -0,0 +1,144 @@ +#include "helix_client.hpp" + +#include <nlohmann/json.hpp> +#include <string> +#include <vector> + +#include "cpr/api.h" +#include "cpr/bearer.h" +#include "cpr/cprtypes.h" +#include "cpr/response.h" +#include "schemas/stream.hpp" +#include "schemas/user.hpp" + +namespace bot::api::twitch { + HelixClient::HelixClient(const std::string &token, + const std::string &client_id) { + this->token = token; + this->client_id = client_id; + } + + std::vector<schemas::User> HelixClient::get_users( + const std::vector<std::string> &logins) const { + std::string s; + + for (auto i = logins.begin(); i != logins.end(); i++) { + std::string start; + if (i == logins.begin()) { + start = "?"; + } else { + start = "&"; + } + + s += start + "login=" + *i; + } + + return this->get_users_by_query(s); + } + + std::vector<schemas::User> HelixClient::get_users( + const std::vector<int> &ids) const { + std::string s; + + for (auto i = ids.begin(); i != ids.end(); i++) { + std::string start; + if (i == ids.begin()) { + start = "?"; + } else { + start = "&"; + } + + s += start + "id=" + std::to_string(*i); + } + + return this->get_users_by_query(s); + } + + std::vector<schemas::User> HelixClient::get_users_by_query( + const std::string &query) const { + cpr::Response response = cpr::Get( + cpr::Url{this->base_url + "/users" + query}, cpr::Bearer{this->token}, + cpr::Header{{"Client-Id", this->client_id.c_str()}}); + + if (response.status_code != 200) { + return {}; + } + + std::vector<schemas::User> users; + + nlohmann::json j = nlohmann::json::parse(response.text); + + for (const auto &d : j["data"]) { + schemas::User u{std::stoi(d["id"].get<std::string>()), d["login"]}; + + users.push_back(u); + } + + return users; + } + + std::vector<schemas::User> HelixClient::get_chatters( + const int &broadcaster_id, const int &moderator_id) const { + cpr::Response response = + cpr::Get(cpr::Url{this->base_url + "/chat/chatters?broadcaster_id=" + + std::to_string(broadcaster_id) + + "&moderator_id=" + std::to_string(moderator_id)}, + cpr::Bearer{this->token}, + cpr::Header{{"Client-Id", this->client_id.c_str()}}); + + if (response.status_code != 200) { + return {}; + } + + std::vector<schemas::User> users; + + nlohmann::json j = nlohmann::json::parse(response.text); + + for (const auto &d : j["data"]) { + schemas::User u{std::stoi(d["user_id"].get<std::string>()), + d["user_login"]}; + + users.push_back(u); + } + + return users; + } + + std::vector<schemas::Stream> HelixClient::get_streams( + const std::vector<int> &ids) const { + std::string s; + + for (auto i = ids.begin(); i != ids.end(); i++) { + std::string start; + if (i == ids.begin()) { + start = "?"; + } else { + start = "&"; + } + + s += start + "user_id=" + std::to_string(*i); + } + + cpr::Response response = cpr::Get( + cpr::Url{this->base_url + "/streams" + s}, cpr::Bearer{this->token}, + cpr::Header{{"Client-Id", this->client_id.c_str()}}); + + if (response.status_code != 200) { + return {}; + } + + std::vector<schemas::Stream> streams; + + nlohmann::json j = nlohmann::json::parse(response.text); + + for (const auto &d : j["data"]) { + schemas::Stream u{std::stoi(d["user_id"].get<std::string>()), + d["user_login"], d["game_name"], d["title"], + d["started_at"]}; + + streams.push_back(u); + } + + return streams; + } +} diff --git a/bot/src/api/twitch/helix_client.hpp b/bot/src/api/twitch/helix_client.hpp new file mode 100644 index 0000000..27a9fa3 --- /dev/null +++ b/bot/src/api/twitch/helix_client.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include <string> +#include <vector> + +#include "schemas/stream.hpp" +#include "schemas/user.hpp" + +namespace bot::api::twitch { + class HelixClient { + public: + HelixClient(const std::string &token, const std::string &client_id); + ~HelixClient() = default; + + std::vector<schemas::User> get_users( + const std::vector<std::string> &logins) const; + std::vector<schemas::User> get_users(const std::vector<int> &ids) const; + + std::vector<schemas::User> get_chatters(const int &broadcaster_id, + const int &moderator_id) const; + + std::vector<schemas::Stream> get_streams( + const std::vector<int> &ids) const; + + private: + std::vector<schemas::User> get_users_by_query( + const std::string &query) const; + std::string token, client_id; + const std::string base_url = "https://api.twitch.tv/helix"; + }; +} diff --git a/bot/src/api/twitch/schemas/stream.hpp b/bot/src/api/twitch/schemas/stream.hpp new file mode 100644 index 0000000..e3d485e --- /dev/null +++ b/bot/src/api/twitch/schemas/stream.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include <chrono> +#include <string> + +#include "../../../utils/chrono.hpp" + +namespace bot::api::twitch::schemas { + class Stream { + public: + Stream(int user_id, std::string user_login, std::string game_name, + std::string title, std::string started_at) + : user_id(user_id), + user_login(user_login), + game_name(game_name), + title(title), + started_at(utils::chrono::string_to_time_point( + started_at, "%Y-%m-%dT%H:%M:%SZ")) {} + + Stream(int user_id) : user_id(user_id) {} + + const int &get_user_id() const { return this->user_id; } + const std::string &get_user_login() const { return this->user_login; } + const std::string &get_game_name() const { return this->game_name; } + const std::string &get_title() const { return this->title; } + const std::chrono::system_clock::time_point &get_started_at() const { + return this->started_at; + } + + private: + int user_id; + std::string user_login, game_name, title; + std::chrono::system_clock::time_point started_at; + }; +} diff --git a/bot/src/api/twitch/schemas/user.hpp b/bot/src/api/twitch/schemas/user.hpp new file mode 100644 index 0000000..288ec72 --- /dev/null +++ b/bot/src/api/twitch/schemas/user.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include <string> + +namespace bot::api::twitch::schemas { + struct User { + int id; + std::string login; + }; +} diff --git a/bot/src/bundle.hpp b/bot/src/bundle.hpp new file mode 100644 index 0000000..d30f5f8 --- /dev/null +++ b/bot/src/bundle.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "api/twitch/helix_client.hpp" +#include "config.hpp" +#include "irc/client.hpp" +#include "localization/localization.hpp" + +namespace bot { + struct InstanceBundle { + irc::Client &irc_client; + const api::twitch::HelixClient &helix_client; + const bot::loc::Localization &localization; + const Configuration &configuration; + }; +} diff --git a/bot/src/commands/command.cpp b/bot/src/commands/command.cpp new file mode 100644 index 0000000..e3b45b1 --- /dev/null +++ b/bot/src/commands/command.cpp @@ -0,0 +1,104 @@ +#include "command.hpp" + +#include <algorithm> +#include <chrono> +#include <ctime> +#include <memory> +#include <optional> +#include <pqxx/pqxx> +#include <string> + +#include "../bundle.hpp" +#include "../modules/custom_command.hpp" +#include "../modules/event.hpp" +#include "../modules/help.hpp" +#include "../modules/join.hpp" +#include "../modules/massping.hpp" +#include "../modules/notify.hpp" +#include "../modules/ping.hpp" +#include "../modules/timer.hpp" +#include "../utils/chrono.hpp" +#include "request.hpp" + +namespace bot { + namespace command { + CommandLoader::CommandLoader() { + this->add_command(std::make_unique<mod::Ping>()); + this->add_command(std::make_unique<mod::Massping>()); + this->add_command(std::make_unique<mod::Event>()); + this->add_command(std::make_unique<mod::Notify>()); + this->add_command(std::make_unique<mod::Join>()); + this->add_command(std::make_unique<mod::CustomCommand>()); + this->add_command(std::make_unique<mod::Timer>()); + this->add_command(std::make_unique<mod::Help>()); + } + + void CommandLoader::add_command(std::unique_ptr<Command> command) { + this->commands.push_back(std::move(command)); + } + + std::optional<std::variant<std::vector<std::string>, std::string>> + CommandLoader::run(const InstanceBundle &bundle, + const Request &request) const { + auto command = std::find_if( + this->commands.begin(), this->commands.end(), + [&](const auto &x) { return x->get_name() == request.command_id; }); + + if (command == this->commands.end()) { + return std::nullopt; + } + + if ((*command)->get_permission_level() > + request.user_rights.get_level()) { + return std::nullopt; + } + + pqxx::work work(request.conn); + + pqxx::result action_query = work.exec( + "SELECT sent_at FROM actions WHERE user_id = " + + std::to_string(request.user.get_id()) + + " AND channel_id = " + std::to_string(request.channel.get_id()) + + " AND command = '" + request.command_id + "' ORDER BY sent_at DESC"); + + if (!action_query.empty()) { + auto last_sent_at = utils::chrono::string_to_time_point( + action_query[0][0].as<std::string>()); + + auto now = std::chrono::system_clock::now(); + auto now_time_it = std::chrono::system_clock::to_time_t(now); + auto now_tm = std::gmtime(&now_time_it); + now = std::chrono::system_clock::from_time_t(std::mktime(now_tm)); + + auto difference = std::chrono::duration_cast<std::chrono::seconds>( + now - last_sent_at); + + if (difference.count() < command->get()->get_delay_seconds()) { + return std::nullopt; + } + } + + std::string arguments; + + if (request.subcommand_id.has_value()) { + arguments += request.subcommand_id.value() + " "; + } + + if (request.message.has_value()) { + arguments += request.message.value(); + } + + work.exec( + "INSERT INTO actions(user_id, channel_id, command, arguments, " + "full_message) VALUES (" + + std::to_string(request.user.get_id()) + ", " + + std::to_string(request.channel.get_id()) + ", '" + + request.command_id + "', '" + arguments + "', '" + + request.irc_message.message + "')"); + + work.commit(); + + return (*command)->run(bundle, request); + } + } +} diff --git a/bot/src/commands/command.hpp b/bot/src/commands/command.hpp new file mode 100644 index 0000000..40ec114 --- /dev/null +++ b/bot/src/commands/command.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include <memory> +#include <optional> +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "request.hpp" + +namespace bot { + namespace command { + enum CommandArgument { + SUBCOMMAND, + MESSAGE, + INTERVAL, + NAME, + TARGET, + VALUE, + AMOUNT, + }; + + class Command { + public: + virtual std::string get_name() const = 0; + virtual std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, const Request &request) const = 0; + virtual schemas::PermissionLevel get_permission_level() const { + return schemas::PermissionLevel::USER; + } + virtual int get_delay_seconds() const { return 5; } + virtual std::vector<std::string> get_subcommand_ids() const { + return {}; + } + }; + + class CommandLoader { + public: + CommandLoader(); + ~CommandLoader() = default; + + void add_command(std::unique_ptr<Command> cmd); + std::optional<std::variant<std::vector<std::string>, std::string>> run( + const InstanceBundle &bundle, const Request &msg) const; + + const std::vector<std::unique_ptr<Command>> &get_commands() const { + return this->commands; + }; + + private: + std::vector<std::unique_ptr<Command>> commands; + }; + } +} diff --git a/bot/src/commands/request.hpp b/bot/src/commands/request.hpp new file mode 100644 index 0000000..e2685f1 --- /dev/null +++ b/bot/src/commands/request.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include <optional> +#include <pqxx/pqxx> +#include <string> + +#include "../irc/message.hpp" +#include "../schemas/channel.hpp" +#include "../schemas/user.hpp" + +namespace bot::command { + struct Request { + std::string command_id; + std::optional<std::string> subcommand_id; + std::optional<std::string> message; + const irc::Message<irc::MessageType::Privmsg> &irc_message; + + schemas::Channel channel; + schemas::ChannelPreferences channel_preferences; + schemas::User user; + schemas::UserRights user_rights; + + pqxx::connection &conn; + }; +} diff --git a/bot/src/commands/request_util.cpp b/bot/src/commands/request_util.cpp new file mode 100644 index 0000000..90750e5 --- /dev/null +++ b/bot/src/commands/request_util.cpp @@ -0,0 +1,205 @@ +#include "request_util.hpp" + +#include <algorithm> +#include <optional> +#include <pqxx/pqxx> +#include <string> + +#include "../constants.hpp" +#include "../irc/message.hpp" +#include "../schemas/channel.hpp" +#include "command.hpp" +#include "request.hpp" + +namespace bot::command { + std::optional<Request> generate_request( + const command::CommandLoader &command_loader, + const irc::Message<irc::MessageType::Privmsg> &irc_message, + pqxx::connection &conn) { + pqxx::work *work; + + work = new pqxx::work(conn); + + std::vector<std::string> parts = + utils::string::split_text(irc_message.message, ' '); + + std::string command_id = parts[0]; + + if (command_id.substr(0, DEFAULT_PREFIX.length()) != DEFAULT_PREFIX) { + delete work; + return std::nullopt; + } + + command_id = + command_id.substr(DEFAULT_PREFIX.length(), command_id.length()); + + auto cmd = std::find_if( + command_loader.get_commands().begin(), + command_loader.get_commands().end(), + [&](const auto &command) { return command->get_name() == command_id; }); + + if (cmd == command_loader.get_commands().end()) { + delete work; + return std::nullopt; + } + + parts.erase(parts.begin()); + + pqxx::result query = work->exec("SELECT * FROM channels WHERE alias_id = " + + std::to_string(irc_message.source.id)); + + // Create new channel data in the database if it didn't exist b4 + if (query.empty()) { + work->exec("INSERT INTO channels (alias_id, alias_name) VALUES (" + + std::to_string(irc_message.source.id) + ", '" + + irc_message.source.login + "')"); + + work->commit(); + + delete work; + work = new pqxx::work(conn); + + query = work->exec("SELECT * FROM channels WHERE alias_id = " + + std::to_string(irc_message.source.id)); + } + + schemas::Channel channel(query[0]); + + if (channel.get_opted_out_at().has_value()) { + delete work; + return std::nullopt; + } + + query = work->exec("SELECT * FROM channel_preferences WHERE channel_id = " + + std::to_string(channel.get_id())); + + // Create new channel preference data in the database if it didn't exist b4 + if (query.empty()) { + work->exec( + "INSERT INTO channel_preferences (channel_id, prefix, locale) VALUES " + "(" + + std::to_string(channel.get_id()) + ", '" + DEFAULT_PREFIX + "', '" + + DEFAULT_LOCALE_ID + "')"); + + work->commit(); + + delete work; + work = new pqxx::work(conn); + + query = + work->exec("SELECT * FROM channel_preferences WHERE channel_id = " + + std::to_string(channel.get_id())); + } + + schemas::ChannelPreferences channel_preferences(query[0]); + + query = work->exec("SELECT * FROM users WHERE alias_id = " + + std::to_string(irc_message.sender.id)); + + // Create new user data in the database if it didn't exist before + if (query.empty()) { + work->exec("INSERT INTO users (alias_id, alias_name) VALUES (" + + std::to_string(irc_message.sender.id) + ", '" + + irc_message.sender.login + "')"); + + work->commit(); + + delete work; + work = new pqxx::work(conn); + + query = work->exec("SELECT * FROM users WHERE alias_id = " + + std::to_string(irc_message.sender.id)); + } + + schemas::User user(query[0]); + + if (user.get_alias_name() != irc_message.sender.login) { + work->exec("UPDATE users SET alias_name = '" + irc_message.sender.login + + "' WHERE id = " + std::to_string(user.get_id())); + work->commit(); + + delete work; + work = new pqxx::work(conn); + + user.set_alias_name(irc_message.sender.login); + } + + schemas::PermissionLevel level = schemas::PermissionLevel::USER; + const auto &badges = irc_message.sender.badges; + + if (user.get_alias_id() == channel.get_alias_id()) { + level = schemas::PermissionLevel::BROADCASTER; + } else if (std::any_of(badges.begin(), badges.end(), [&](const auto &x) { + return x.first == "moderator"; + })) { + level = schemas::PermissionLevel::MODERATOR; + } else if (std::any_of(badges.begin(), badges.end(), + [&](const auto &x) { return x.first == "vip"; })) { + level = schemas::PermissionLevel::VIP; + } + + query = work->exec("SELECT * FROM user_rights WHERE user_id = " + + std::to_string(user.get_id()) + + " AND channel_id = " + std::to_string(channel.get_id())); + + if (query.empty()) { + work->exec( + "INSERT INTO user_rights (user_id, channel_id, level) VALUES (" + + std::to_string(user.get_id()) + ", " + + std::to_string(channel.get_id()) + ", " + std::to_string(level) + + ")"); + + work->commit(); + + delete work; + work = new pqxx::work(conn); + + query = work->exec("SELECT * FROM user_rights WHERE user_id = " + + std::to_string(user.get_id()) + " AND channel_id = " + + std::to_string(channel.get_id())); + } + + schemas::UserRights user_rights(query[0]); + + if (user_rights.get_level() != level) { + work->exec("UPDATE user_rights SET level = " + std::to_string(level) + + " WHERE id = " + std::to_string(query[0][0].as<int>())); + + work->commit(); + + user_rights.set_level(level); + } + + delete work; + + if (parts.empty()) { + Request req{command_id, std::nullopt, std::nullopt, + irc_message, channel, channel_preferences, + user, user_rights, conn}; + + return req; + } + + std::optional<std::string> subcommand_id = parts[0]; + auto subcommand_ids = (*cmd)->get_subcommand_ids(); + + if (std::any_of( + subcommand_ids.begin(), subcommand_ids.end(), + [&](const auto &x) { return x == subcommand_id.value(); })) { + parts.erase(parts.begin()); + } else { + subcommand_id = std::nullopt; + } + + std::optional<std::string> message = utils::string::join_vector(parts, ' '); + + if (message->empty()) { + message = std::nullopt; + } + + Request req{command_id, subcommand_id, message, + irc_message, channel, channel_preferences, + user, user_rights, conn}; + return req; + } +} diff --git a/bot/src/commands/request_util.hpp b/bot/src/commands/request_util.hpp new file mode 100644 index 0000000..dea6e12 --- /dev/null +++ b/bot/src/commands/request_util.hpp @@ -0,0 +1,13 @@ +#include <optional> +#include <pqxx/pqxx> + +#include "../irc/message.hpp" +#include "command.hpp" +#include "request.hpp" + +namespace bot::command { + std::optional<Request> generate_request( + const command::CommandLoader &command_loader, + const irc::Message<irc::MessageType::Privmsg> &irc_message, + pqxx::connection &conn); +} diff --git a/bot/src/commands/response_error.hpp b/bot/src/commands/response_error.hpp new file mode 100644 index 0000000..ae2c3ee --- /dev/null +++ b/bot/src/commands/response_error.hpp @@ -0,0 +1,222 @@ +#pragma once + +#include <exception> +#include <optional> +#include <string> +#include <type_traits> +#include <vector> + +#include "command.hpp" +#include "request.hpp" + +namespace bot { + enum ResponseError { + NOT_ENOUGH_ARGUMENTS, + INCORRECT_ARGUMENT, + + INCOMPATIBLE_NAME, + NAMESAKE_CREATION, + NOT_FOUND, + + SOMETHING_WENT_WRONG, + + EXTERNAL_API_ERROR, + INSUFFICIENT_RIGHTS, + + ILLEGAL_COMMAND + }; + + template <ResponseError T, class Enable = void> + class ResponseException; + + template <ResponseError T> + class ResponseException< + T, typename std::enable_if< + T == INCORRECT_ARGUMENT || T == INCOMPATIBLE_NAME || + T == NAMESAKE_CREATION || T == NOT_FOUND>::type> + : public std::exception { + public: + ResponseException(const command::Request &request, + const loc::Localization &localizator, + const std::string &message) + : request(request), + localizator(localizator), + message(message), + error(T) { + loc::LineId line_id; + + switch (this->error) { + case INCORRECT_ARGUMENT: + line_id = loc::LineId::ErrorIncorrectArgument; + break; + case INCOMPATIBLE_NAME: + line_id = loc::LineId::ErrorIncompatibleName; + break; + case NAMESAKE_CREATION: + line_id = loc::LineId::ErrorNamesakeCreation; + break; + case NOT_FOUND: + line_id = loc::LineId::ErrorNotFound; + break; + default: + line_id = loc::LineId::ErrorSomethingWentWrong; + break; + }; + + this->line = + this->localizator + .get_formatted_line(this->request, line_id, {this->message}) + .value(); + } + ~ResponseException() = default; + + const char *what() const noexcept override { return this->line.c_str(); } + + private: + const command::Request &request; + const loc::Localization &localizator; + std::string message, line; + ResponseError error; + }; + + template <ResponseError T> + class ResponseException<T, + typename std::enable_if<T == SOMETHING_WENT_WRONG || + T == INSUFFICIENT_RIGHTS || + T == ILLEGAL_COMMAND>::type> + : public std::exception { + public: + ResponseException(const command::Request &request, + const loc::Localization &localizator) + : request(request), localizator(localizator), error(T) { + loc::LineId line_id; + + switch (this->error) { + case INSUFFICIENT_RIGHTS: + line_id = loc::LineId::ErrorInsufficientRights; + break; + case ILLEGAL_COMMAND: + line_id = loc::LineId::ErrorIllegalCommand; + break; + default: + line_id = loc::LineId::ErrorSomethingWentWrong; + break; + } + + this->line = + this->localizator.get_formatted_line(this->request, line_id, {}) + .value(); + } + ~ResponseException() = default; + + const char *what() const noexcept override { return this->line.c_str(); } + + private: + const command::Request &request; + const loc::Localization &localizator; + std::string line; + ResponseError error; + }; + + template <ResponseError T> + class ResponseException< + T, typename std::enable_if<T == EXTERNAL_API_ERROR>::type> + : public std::exception { + public: + ResponseException( + const command::Request &request, const loc::Localization &localizator, + const int &code, + const std::optional<std::string> &message = std::nullopt) + : request(request), + localizator(localizator), + code(code), + message(message), + error(T) { + loc::LineId line_id = loc::LineId::ErrorExternalAPIError; + std::vector<std::string> args = {std::to_string(this->code)}; + + if (this->message.has_value()) { + args.push_back(" " + this->message.value()); + } + + this->line = + this->localizator.get_formatted_line(this->request, line_id, args) + .value(); + } + ~ResponseException() = default; + + const char *what() const noexcept override { return this->line.c_str(); } + + private: + const command::Request &request; + const loc::Localization &localizator; + int code; + std::optional<std::string> message; + std::string line; + ResponseError error; + }; + + template <ResponseError T> + class ResponseException< + T, typename std::enable_if<T == NOT_ENOUGH_ARGUMENTS>::type> + : public std::exception { + public: + ResponseException(const command::Request &request, + const loc::Localization &localizator, + command::CommandArgument argument) + : request(request), + localizator(localizator), + argument(argument), + error(T) { + loc::LineId line_id = loc::LineId::ErrorNotEnoughArguments; + loc::LineId arg_id; + + switch (this->argument) { + case command::SUBCOMMAND: + arg_id = loc::LineId::ArgumentSubcommand; + break; + case command::MESSAGE: + arg_id = loc::LineId::ArgumentMessage; + break; + case command::INTERVAL: + arg_id = loc::LineId::ArgumentInterval; + break; + case command::NAME: + arg_id = loc::LineId::ArgumentName; + break; + case command::TARGET: + arg_id = loc::LineId::ArgumentTarget; + break; + case command::VALUE: + arg_id = loc::LineId::ArgumentValue; + break; + case command::AMOUNT: + arg_id = loc::LineId::ArgumentAmount; + break; + default: + break; + } + + auto arg = + this->localizator + .get_localized_line( + this->request.channel_preferences.get_locale(), arg_id) + .value(); + + this->line = + this->localizator.get_formatted_line(this->request, line_id, {arg}) + .value(); + } + ~ResponseException() = default; + + const char *what() const noexcept override { return this->line.c_str(); } + + private: + const command::Request &request; + const loc::Localization &localizator; + command::CommandArgument argument; + ResponseError error; + std::string line; + }; + +} diff --git a/bot/src/config.cpp b/bot/src/config.cpp new file mode 100644 index 0000000..ec55913 --- /dev/null +++ b/bot/src/config.cpp @@ -0,0 +1,84 @@ +#include "config.hpp" + +#include <cctype> +#include <fstream> +#include <optional> +#include <sstream> +#include <string> + +#include "logger.hpp" + +namespace bot { + std::optional<Configuration> parse_configuration_from_file( + const std::string &file_path) { + std::ifstream ifs(file_path); + + if (!ifs.is_open()) { + log::error("Configuration", "Failed to open the file at " + file_path); + return std::nullopt; + } + + Configuration cfg; + TwitchCredentialsConfiguration ttv_crd_cfg; + DatabaseConfiguration db_cfg; + CommandConfiguration cmd_cfg; + OwnerConfiguration owner_cfg; + UrlConfiguration url_cfg; + + std::string line; + while (std::getline(ifs, line, '\n')) { + std::istringstream iss(line); + std::string key; + std::string value; + + std::getline(iss, key, '='); + std::getline(iss, value); + + for (char &c : key) { + c = tolower(c); + } + + if (key == "twitch_credentials.client_id") { + ttv_crd_cfg.client_id = value; + } else if (key == "twitch_credentials.token") { + ttv_crd_cfg.token = value; + } else if (key == "db_name") { + db_cfg.name = value; + } else if (key == "db_user") { + db_cfg.user = value; + } else if (key == "db_password") { + db_cfg.password = value; + } else if (key == "db_host") { + db_cfg.host = value; + } else if (key == "db_port") { + db_cfg.port = value; + } + + else if (key == "commands.join_allowed") { + cmd_cfg.join_allowed = std::stoi(value); + } else if (key == "commands.join_allow_from_other_chats") { + cmd_cfg.join_allow_from_other_chats = std::stoi(value); + } + + else if (key == "owner.name") { + owner_cfg.name = value; + } else if (key == "owner.id") { + owner_cfg.id = std::stoi(value); + } + + else if (key == "url.help") { + url_cfg.help = value; + } + } + + cfg.url = url_cfg; + cfg.owner = owner_cfg; + cfg.commands = cmd_cfg; + cfg.twitch_credentials = ttv_crd_cfg; + cfg.database = db_cfg; + + log::info("Configuration", + "Successfully loaded the file from '" + file_path + "'"); + return cfg; + } +} diff --git a/bot/src/config.hpp b/bot/src/config.hpp new file mode 100644 index 0000000..5c437d6 --- /dev/null +++ b/bot/src/config.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include <optional> +#include <string> + +#define GET_DATABASE_CONNECTION_URL(c) \ + "dbname = " + c.database.name + " user = " + c.database.user + \ + " password = " + c.database.password + " host = " + c.database.host + \ + " port = " + c.database.port + +#define GET_DATABASE_CONNECTION_URL_POINTER(c) \ + "dbname = " + c->database.name + " user = " + c->database.user + \ + " password = " + c->database.password + " host = " + c->database.host + \ + " port = " + c->database.port + +namespace bot { + struct DatabaseConfiguration { + std::string name; + std::string user; + std::string password; + std::string host; + std::string port; + }; + + struct TwitchCredentialsConfiguration { + std::string client_id; + std::string token; + }; + + struct CommandConfiguration { + bool join_allowed = true; + bool join_allow_from_other_chats = false; + }; + + struct OwnerConfiguration { + std::optional<std::string> name = std::nullopt; + std::optional<int> id = std::nullopt; + }; + + struct UrlConfiguration { + std::optional<std::string> help = std::nullopt; + }; + + struct Configuration { + TwitchCredentialsConfiguration twitch_credentials; + DatabaseConfiguration database; + CommandConfiguration commands; + OwnerConfiguration owner; + UrlConfiguration url; + }; + + std::optional<Configuration> parse_configuration_from_file( + const std::string &file_path); +} diff --git a/bot/src/constants.hpp b/bot/src/constants.hpp new file mode 100644 index 0000000..3c3462b --- /dev/null +++ b/bot/src/constants.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include <chrono> +#include <string> + +#define DEFAULT_LOCALE_ID "english" + +#ifdef DEBUG_MODE +const std::string DEFAULT_PREFIX = "~"; +#else +const std::string DEFAULT_PREFIX = "!"; +#endif +const auto START_TIME = std::chrono::steady_clock::now(); diff --git a/bot/src/handlers.cpp b/bot/src/handlers.cpp new file mode 100644 index 0000000..c7820b4 --- /dev/null +++ b/bot/src/handlers.cpp @@ -0,0 +1,85 @@ +#include "handlers.hpp" + +#include <exception> +#include <optional> +#include <pqxx/pqxx> +#include <string> +#include <vector> + +#include "bundle.hpp" +#include "commands/command.hpp" +#include "commands/request.hpp" +#include "commands/request_util.hpp" +#include "irc/message.hpp" +#include "localization/line_id.hpp" +#include "logger.hpp" +#include "utils/string.hpp" + +namespace bot::handlers { + void handle_private_message( + const InstanceBundle &bundle, + const command::CommandLoader &command_loader, + const irc::Message<irc::MessageType::Privmsg> &message, + pqxx::connection &conn) { + if (utils::string::string_contains_sql_injection(message.message)) { + log::warn("PrivateMessageHandler", + "Received the message in #" + message.source.login + + " with SQL injection: " + message.message); + return; + } + + std::optional<command::Request> request = + command::generate_request(command_loader, message, conn); + + if (request.has_value()) { + try { + auto response = command_loader.run(bundle, request.value()); + + if (response.has_value()) { + try { + auto str = std::get<std::string>(*response); + bundle.irc_client.say(message.source.login, str); + } catch (const std::exception &e) { + } + + try { + auto strs = std::get<std::vector<std::string>>(*response); + for (const std::string &str : strs) { + bundle.irc_client.say(message.source.login, str); + } + } catch (const std::exception &e) { + } + } + } catch (const std::exception &e) { + std::string line = + bundle.localization + .get_formatted_line(request.value(), loc::LineId::ErrorTemplate, + {e.what()}) + .value(); + + bundle.irc_client.say(message.source.login, line); + } + } + + pqxx::work work(conn); + pqxx::result channels = + work.exec("SELECT id FROM channels WHERE alias_id = " + + std::to_string(message.source.id)); + + if (!channels.empty()) { + int channel_id = channels[0][0].as<int>(); + pqxx::result cmds = + work.exec("SELECT message FROM custom_commands WHERE name = '" + + message.message + "' AND channel_id = '" + + std::to_string(channel_id) + "'"); + + if (!cmds.empty()) { + std::string msg = cmds[0][0].as<std::string>(); + + bundle.irc_client.say(message.source.login, msg); + } + } + + work.commit(); + } +} diff --git a/bot/src/handlers.hpp b/bot/src/handlers.hpp new file mode 100644 index 0000000..a143f76 --- /dev/null +++ b/bot/src/handlers.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "bundle.hpp" +#include "commands/command.hpp" +#include "irc/message.hpp" +#include "pqxx/pqxx" + +namespace bot::handlers { + void handle_private_message( + const InstanceBundle &bundle, + const command::CommandLoader &command_loader, + const irc::Message<irc::MessageType::Privmsg> &message, + pqxx::connection &conn); +} diff --git a/bot/src/irc/client.cpp b/bot/src/irc/client.cpp new file mode 100644 index 0000000..018736e --- /dev/null +++ b/bot/src/irc/client.cpp @@ -0,0 +1,155 @@ +#include "client.hpp" + +#include <ixwebsocket/IXWebSocketMessage.h> +#include <ixwebsocket/IXWebSocketMessageType.h> + +#include <algorithm> +#include <optional> +#include <string> +#include <vector> + +#include "../logger.hpp" +#include "cpr/api.h" +#include "cpr/cprtypes.h" +#include "cpr/response.h" +#include "message.hpp" +#include "nlohmann/json.hpp" + +using namespace bot::irc; + +Client::Client(std::string client_id, std::string token) { + this->client_id = client_id; + this->token = token; + + this->host = "wss://irc-ws.chat.twitch.tv"; + this->port = "443"; + + this->websocket.setUrl(this->host + ":" + this->port); + + // getting token owner + cpr::Response response = cpr::Get( + cpr::Url{"https://api.twitch.tv/helix/users"}, cpr::Bearer{this->token}, + cpr::Header{{"Client-Id", this->client_id}}); + + if (response.status_code != 200) { + log::warn("IRC", "Failed to get bot username from Twitch API: " + + std::to_string(response.status_code) + " " + + response.status_line); + } else { + nlohmann::json j = nlohmann::json::parse(response.text); + + auto d = j["data"][0]; + this->id = std::stoi(d["id"].get<std::string>()); + this->username = d["login"]; + } +} + +void Client::run() { + this->websocket.setOnMessageCallback( + [this](const ix::WebSocketMessagePtr &msg) { + switch (msg->type) { + case ix::WebSocketMessageType::Message: { + log::debug("IRC", "Received message: " + msg->str); + + std::vector<std::string> lines = + utils::string::split_text(msg->str, '\n'); + + for (std::string &line : lines) { + line.erase(std::remove_if(line.begin(), line.end(), + [](char c) { + return c == '\n' || c == '\r' || + c == '\t'; + }), + line.end()); + + std::optional<MessageType> type = define_message_type(line); + + if (!type.has_value()) { + break; + } + + MessageType m_type = type.value(); + + if (m_type == MessageType::Privmsg) { + std::optional<Message<MessageType::Privmsg>> message = + parse_message<MessageType::Privmsg>(line); + + if (message.has_value()) { + this->onPrivmsg(message.value()); + } + } + } + + break; + } + case ix::WebSocketMessageType::Open: { + log::info("IRC", "Connected to Twitch IRC"); + this->is_connected = true; + this->authorize(); + for (const auto &msg : this->pool) { + this->websocket.send(msg); + } + this->pool.clear(); + break; + } + case ix::WebSocketMessageType::Close: { + log::info("IRC", "Twitch IRC connection closed"); + this->is_connected = false; + + for (const auto &x : this->joined_channels) { + this->raw("JOIN #" + x); + } + + break; + } + default: { + break; + } + } + }); + + this->websocket.start(); +} + +void Client::say(const std::string &channel_login, const std::string &message) { + this->raw("PRIVMSG #" + channel_login + " :" + message); + log::debug("IRC", "Sent '" + message + "' in #" + channel_login); +} + +bool Client::join(const std::string &channel_login) { + auto already_joined = + std::any_of(this->joined_channels.begin(), this->joined_channels.end(), + [&](const auto &x) { return x == channel_login; }); + + if (!already_joined) { + this->raw("JOIN #" + channel_login); + this->joined_channels.push_back(channel_login); + log::info("IRC", "Joined #" + channel_login); + } + + return !already_joined; +} + +void Client::raw(const std::string &raw_message) { + std::string msg = raw_message + "\r\n"; + if (this->is_connected) { + this->websocket.send(msg); + } else { + this->pool.push_back(msg); + } +} + +void Client::authorize() { + if (this->username.empty() || this->token.empty()) { + log::error("IRC", "Bot username and token must be set for authorization!"); + return; + } + + log::info("IRC", "Authorizing on Twitch IRC servers..."); + + this->raw("PASS oauth:" + this->token); + this->raw("NICK " + this->username); + this->raw("CAP REQ :twitch.tv/membership"); + this->raw("CAP REQ :twitch.tv/commands"); + this->raw("CAP REQ :twitch.tv/tags"); +} diff --git a/bot/src/irc/client.hpp b/bot/src/irc/client.hpp new file mode 100644 index 0000000..cff867f --- /dev/null +++ b/bot/src/irc/client.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include <ixwebsocket/IXWebSocket.h> + +#include <string> +#include <vector> + +#include "message.hpp" + +namespace bot { + namespace irc { + class Client { + public: + Client(std::string client_id, std::string token); + ~Client() = default; + + void run(); + + void say(const std::string &channel_login, const std::string &message); + bool join(const std::string &channel_login); + void raw(const std::string &raw_message); + + template <MessageType T> + void on(typename MessageHandler<T>::fn function) { + switch (T) { + case Privmsg: + this->onPrivmsg = function; + break; + default: + break; + } + } + + const std::string &get_bot_username() const { return this->username; }; + const int &get_bot_id() const { return this->id; } + + private: + void authorize(); + + std::string client_id, token, username; + + std::string host; + std::string port; + + int id; + + ix::WebSocket websocket; + + bool is_connected = false; + std::vector<std::string> pool; + + std::vector<std::string> joined_channels; + + // Message handlers + typename MessageHandler<MessageType::Privmsg>::fn onPrivmsg; + }; + } +} diff --git a/bot/src/irc/message.cpp b/bot/src/irc/message.cpp new file mode 100644 index 0000000..569e691 --- /dev/null +++ b/bot/src/irc/message.cpp @@ -0,0 +1,30 @@ +#include "message.hpp" + +#include <optional> +#include <string> +#include <vector> + +namespace bot { + namespace irc { + std::optional<MessageType> define_message_type(const std::string &msg) { + std::vector<std::string> parts = utils::string::split_text(msg, ' '); + int i; + + if (msg[0] == '@') { + i = 2; + } else if (msg[0] == ':') { + i = 1; + } else { + return std::nullopt; + } + + if (parts[i] == "NOTICE") { + return MessageType::Notice; + } else if (parts[i] == "PRIVMSG") { + return MessageType::Privmsg; + } + + return std::nullopt; + } + } +} diff --git a/bot/src/irc/message.hpp b/bot/src/irc/message.hpp new file mode 100644 index 0000000..164d7ca --- /dev/null +++ b/bot/src/irc/message.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include <functional> +#include <map> +#include <optional> +#include <sstream> +#include <string> +#include <vector> + +#include "../utils/string.hpp" + +namespace bot { + namespace irc { + enum MessageType { Privmsg, Notice }; + std::optional<MessageType> define_message_type(const std::string &msg); + + struct MessageSender { + std::string login; + std::string display_name; + int id; + + std::map<std::string, int> badges; + + // More fields will be here + }; + + struct MessageSource { + std::string login; + int id; + }; + + template <MessageType T> + struct Message; + + template <> + struct Message<MessageType::Privmsg> { + MessageSender sender; + MessageSource source; + std::string message; + }; + + template <MessageType T> + std::optional<Message<T>> parse_message(const std::string &msg) { + std::vector<std::string> parts = utils::string::split_text(msg, ' '); + + if (T == MessageType::Privmsg) { + MessageSender sender; + MessageSource source; + + Message<MessageType::Privmsg> message; + + std::string tags = parts[0]; + tags = tags.substr(1, tags.length()); + parts.erase(parts.begin()); + + std::string user = parts[0]; + user = user.substr(1, user.length()); + + std::vector<std::string> user_parts = + utils::string::split_text(user, '!'); + + sender.login = user_parts[0]; + + parts.erase(parts.begin(), parts.begin() + 2); + + std::string channel_login = parts[0]; + source.login = channel_login.substr(1, channel_login.length()); + + parts.erase(parts.begin()); + + std::string chat_message = utils::string::join_vector(parts, ' '); + message.message = chat_message.substr(1, chat_message.length()); + + std::vector<std::string> tags_parts = + utils::string::split_text(tags, ';'); + + for (const std::string &tag : tags_parts) { + std::istringstream iss(tag); + std::string key; + std::string value; + + std::getline(iss, key, '='); + std::getline(iss, value); + + if (key == "display-name") { + sender.display_name = value; + } else if (key == "room-id") { + source.id = std::stoi(value); + } else if (key == "user-id") { + sender.id = std::stoi(value); + } else if (key == "badges") { + std::vector<std::string> badges = + utils::string::split_text(value, ','); + + std::map<std::string, int> map; + + for (const auto &badge : badges) { + std::istringstream iss2(badge); + std::string name; + std::string value; + + std::getline(iss2, name, '/'); + std::getline(iss2, value); + + map.insert({name, std::stoi(value)}); + } + + sender.badges = map; + } + } + + message.sender = sender; + message.source = source; + + return message; + } + + return std::nullopt; + } + + template <MessageType T> + struct MessageHandler; + + template <> + struct MessageHandler<MessageType::Privmsg> { + using fn = std::function<void(Message<Privmsg> message)>; + }; + + } +} diff --git a/bot/src/localization/line_id.cpp b/bot/src/localization/line_id.cpp new file mode 100644 index 0000000..567a3ba --- /dev/null +++ b/bot/src/localization/line_id.cpp @@ -0,0 +1,100 @@ +#include "line_id.hpp" + +#include <optional> +#include <string> + +namespace bot { + namespace loc { + std::optional<LineId> string_to_line_id(const std::string &str) { + if (str == "ping.response") { + return LineId::PingResponse; + } + + else if (str == "msg.owner") { + return LineId::MsgOwner; + } + + else if (str == "argument.subcommand") { + return LineId::ArgumentSubcommand; + } else if (str == "argument.message") { + return LineId::ArgumentMessage; + } else if (str == "argument.interval") { + return LineId::ArgumentInterval; + } else if (str == "argument.name") { + return LineId::ArgumentName; + } else if (str == "argument.target") { + return LineId::ArgumentTarget; + } else if (str == "argument.value") { + return LineId::ArgumentValue; + } else if (str == "argument.amount") { + return LineId::ArgumentAmount; + } + + else if (str == "error.template") { + return LineId::ErrorTemplate; + } else if (str == "error.not_enough_arguments") { + return LineId::ErrorNotEnoughArguments; + } else if (str == "error.incorrect_argument") { + return LineId::ErrorIncorrectArgument; + } else if (str == "error.incompatible_name") { + return LineId::ErrorIncompatibleName; + } else if (str == "error.namesake_creation") { + return LineId::ErrorNamesakeCreation; + } else if (str == "error.not_found") { + return LineId::ErrorNotFound; + } else if (str == "error.something_went_wrong") { + return LineId::ErrorSomethingWentWrong; + } else if (str == "error.insufficient_rights") { + return LineId::ErrorInsufficientRights; + } else if (str == "error.illegal_command") { + return LineId::ErrorIllegalCommand; + } + + else if (str == "event.on") { + return LineId::EventOn; + } else if (str == "event.off") { + return LineId::EventOff; + } + + else if (str == "notify.sub") { + return LineId::NotifySub; + } else if (str == "notify.unsub") { + return LineId::NotifyUnsub; + } + + else if (str == "join.response") { + return LineId::JoinResponse; + } else if (str == "join.response_in_chat") { + return LineId::JoinResponseInChat; + } else if (str == "join.already_in") { + return LineId::JoinAlreadyIn; + } else if (str == "join.rejoined") { + return LineId::JoinRejoined; + } else if (str == "join.from_other_chat") { + return LineId::JoinFromOtherChat; + } else if (str == "join.not_allowed") { + return LineId::JoinNotAllowed; + } + + else if (str == "custom_command.new") { + return LineId::CustomcommandNew; + } else if (str == "custom_command.delete") { + return LineId::CustomcommandDelete; + } + + else if (str == "timer.new") { + return LineId::TimerNew; + } else if (str == "timer.delete") { + return LineId::TimerDelete; + } + + else if (str == "help.response") { + return LineId::HelpResponse; + } + + else { + return std::nullopt; + } + } + } +} diff --git a/bot/src/localization/line_id.hpp b/bot/src/localization/line_id.hpp new file mode 100644 index 0000000..41ceec6 --- /dev/null +++ b/bot/src/localization/line_id.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include <optional> +#include <string> + +namespace bot { + namespace loc { + enum LineId { + MsgOwner, + + ArgumentSubcommand, + ArgumentMessage, + ArgumentInterval, + ArgumentName, + ArgumentTarget, + ArgumentValue, + ArgumentAmount, + + ErrorTemplate, + ErrorNotEnoughArguments, + ErrorIncorrectArgument, + ErrorIncompatibleName, + ErrorNamesakeCreation, + ErrorNotFound, + ErrorSomethingWentWrong, + ErrorExternalAPIError, + ErrorInsufficientRights, + ErrorIllegalCommand, + + PingResponse, + + EventOn, + EventOff, + + NotifySub, + NotifyUnsub, + + JoinResponse, + JoinResponseInChat, + JoinAlreadyIn, + JoinRejoined, + JoinFromOtherChat, + JoinNotAllowed, + + CustomcommandNew, + CustomcommandDelete, + + TimerNew, + TimerDelete, + + HelpResponse + }; + + std::optional<LineId> string_to_line_id(const std::string &str); + } +} diff --git a/bot/src/localization/localization.cpp b/bot/src/localization/localization.cpp new file mode 100644 index 0000000..2742602 --- /dev/null +++ b/bot/src/localization/localization.cpp @@ -0,0 +1,132 @@ +#include "localization.hpp" + +#include <algorithm> +#include <filesystem> +#include <fstream> +#include <map> +#include <nlohmann/json.hpp> +#include <optional> +#include <string> +#include <unordered_map> +#include <vector> + +#include "../utils/string.hpp" +#include "line_id.hpp" + +namespace bot { + namespace loc { + Localization::Localization(const std::string &folder_path) { + for (const auto &entry : + std::filesystem::directory_iterator(folder_path)) { + std::vector<std::string> file_name_parts = + utils::string::split_text(entry.path(), '/'); + std::string file_name = file_name_parts[file_name_parts.size() - 1]; + file_name = file_name.substr(0, file_name.length() - 5); + + std::unordered_map<LineId, std::string> lines = + this->load_from_file(entry.path()); + + this->localizations[file_name] = lines; + } + } + + std::unordered_map<LineId, std::string> Localization::load_from_file( + const std::string &file_path) { + std::ifstream ifs(file_path); + + std::unordered_map<LineId, std::string> map; + + nlohmann::json json; + ifs >> json; + + for (auto it = json.begin(); it != json.end(); ++it) { + std::optional<LineId> line_id = string_to_line_id(it.key()); + + if (line_id.has_value()) { + map[line_id.value()] = it.value(); + } + } + + ifs.close(); + return map; + } + + std::optional<std::string> Localization::get_localized_line( + const std::string &locale_id, const LineId &line_id) const { + auto locale_it = + std::find_if(this->localizations.begin(), this->localizations.end(), + [&](const auto &x) { return x.first == locale_id; }); + + if (locale_it == this->localizations.end()) { + return std::nullopt; + } + + auto line_it = + std::find_if(locale_it->second.begin(), locale_it->second.end(), + [&](const auto &x) { return x.first == line_id; }); + + if (line_it == locale_it->second.end()) { + return std::nullopt; + } + + return line_it->second; + } + + std::optional<std::string> Localization::get_formatted_line( + const std::string &locale_id, const LineId &line_id, + const std::vector<std::string> &args) const { + std::optional<std::string> o_line = + this->get_localized_line(locale_id, line_id); + + if (!o_line.has_value()) { + return std::nullopt; + } + + std::string line = o_line.value(); + + int pos = 0; + int index = 0; + + while ((pos = line.find("%s", pos)) != std::string::npos) { + line.replace(pos, 2, args[index]); + pos += args[index].size(); + ++index; + + if (index >= args.size()) { + break; + } + } + + return line; + } + + std::optional<std::string> Localization::get_formatted_line( + const command::Request &request, const LineId &line_id, + const std::vector<std::string> &args) const { + std::optional<std::string> o_line = this->get_formatted_line( + request.channel_preferences.get_locale(), line_id, args); + + if (!o_line.has_value()) { + return std::nullopt; + } + + std::string line = o_line.value(); + + std::map<std::string, std::string> token_map = { + {"{sender.alias_name}", request.user.get_alias_name()}, + {"{source.alias_name}", request.channel.get_alias_name()}, + {"{default.prefix}", DEFAULT_PREFIX}}; + + for (const auto &pair : token_map) { + int pos = line.find(pair.first); + + while (pos != std::string::npos) { + line.replace(pos, pair.first.length(), pair.second); + pos = line.find(pair.first, pos + pair.second.length()); + } + } + + return line; + } + } +} diff --git a/bot/src/localization/localization.hpp b/bot/src/localization/localization.hpp new file mode 100644 index 0000000..4626c68 --- /dev/null +++ b/bot/src/localization/localization.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include <optional> +#include <string> +#include <unordered_map> +#include <vector> + +#include "../commands/request.hpp" +#include "line_id.hpp" + +namespace bot { + namespace loc { + class Localization { + public: + Localization(const std::string &folder_path); + ~Localization() = default; + + std::optional<std::string> get_localized_line( + const std::string &locale_id, const LineId &line_id) const; + + std::optional<std::string> get_formatted_line( + const std::string &locale_id, const LineId &line_id, + const std::vector<std::string> &args) const; + + std::optional<std::string> get_formatted_line( + const command::Request &request, const LineId &line_id, + const std::vector<std::string> &args) const; + + private: + std::unordered_map<LineId, std::string> load_from_file( + const std::string &file_path); + std::unordered_map<std::string, std::unordered_map<LineId, std::string>> + localizations; + }; + } + +} diff --git a/bot/src/logger.cpp b/bot/src/logger.cpp new file mode 100644 index 0000000..3d142a2 --- /dev/null +++ b/bot/src/logger.cpp @@ -0,0 +1,96 @@ +#include "logger.hpp" + +#include <ctime> +#include <filesystem> +#include <fstream> +#include <iomanip> +#include <iostream> +#include <sstream> +#include <stdexcept> + +namespace bot::log { + void log(const LogLevel &level, const std::string &source, + const std::string &message) { + std::string dir_name = "logs"; + if (!std::filesystem::exists(dir_name)) { + std::filesystem::create_directory(dir_name); + } + + if (std::filesystem::exists(dir_name) && + !std::filesystem::is_directory(dir_name)) { + throw std::runtime_error("The path '" + dir_name + + "' is not a directory!"); + return; + } + + std::ostringstream line; + + // getting time + std::time_t current_time = std::time(nullptr); + std::tm *local_time = std::localtime(¤t_time); + + line << "[" << std::put_time(local_time, "%H:%M:%S") << "] "; + + std::string level_str; + + switch (level) { + case DEBUG: + level_str = "DEBUG"; + break; + case WARN: + level_str = "WARN"; + break; + case ERROR: + level_str = "ERROR"; + break; + default: + level_str = "INFO"; + break; + } + + line << level_str << " - "; + + line << source << ": " << message << "\n"; + +#ifdef DEBUG_MODE + std::cout << line.str(); +#else + if (level != LogLevel::DEBUG) { + std::cout << line.str(); + } +#endif + + // saving into the log file + std::ostringstream file_name_oss; + file_name_oss << dir_name << "/"; + file_name_oss << "log_"; + file_name_oss << std::put_time(local_time, "%Y-%m-%d"); + file_name_oss << ".log"; + + std::ofstream ofs; + ofs.open(file_name_oss.str(), std::ios::app); + + if (ofs.is_open()) { + ofs << line.str(); + ofs.close(); + } else { + std::cerr << "Failed to write to the log file!\n"; + } + } + + void info(const std::string &source, const std::string &message) { + log(LogLevel::INFO, source, message); + } + + void debug(const std::string &source, const std::string &message) { + log(LogLevel::DEBUG, source, message); + } + + void warn(const std::string &source, const std::string &message) { + log(LogLevel::WARN, source, message); + } + + void error(const std::string &source, const std::string &message) { + log(LogLevel::ERROR, source, message); + } +} diff --git a/bot/src/logger.hpp b/bot/src/logger.hpp new file mode 100644 index 0000000..91b4757 --- /dev/null +++ b/bot/src/logger.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include <string> + +namespace bot::log { + enum LogLevel { INFO, DEBUG, WARN, ERROR }; + + void log(const LogLevel &level, const std::string &source, + const std::string &message); + + // just shorthands + void info(const std::string &source, const std::string &message); + void debug(const std::string &source, const std::string &message); + void warn(const std::string &source, const std::string &message); + void error(const std::string &source, const std::string &message); +} diff --git a/bot/src/main.cpp b/bot/src/main.cpp new file mode 100644 index 0000000..3c8f5e7 --- /dev/null +++ b/bot/src/main.cpp @@ -0,0 +1,127 @@ +#include <optional> +#include <pqxx/pqxx> +#include <string> +#include <vector> + +#include "api/twitch/helix_client.hpp" +#include "bundle.hpp" +#include "commands/command.hpp" +#include "config.hpp" +#include "handlers.hpp" +#include "irc/client.hpp" +#include "irc/message.hpp" +#include "localization/localization.hpp" +#include "logger.hpp" +#include "stream.hpp" +#include "timer.hpp" + +int main(int argc, char *argv[]) { + bot::log::info("Main", "Starting up..."); + + std::optional<bot::Configuration> o_cfg = + bot::parse_configuration_from_file(".env"); + + if (!o_cfg.has_value()) { + return 1; + } + + bot::Configuration cfg = o_cfg.value(); + + if (cfg.twitch_credentials.client_id.empty() || + cfg.twitch_credentials.token.empty()) { + bot::log::error("Main", + "TWITCH_CREDENTIALS.CLIENT_ID and TWITCH_CREDENTIALS.TOKEN " + "must be set in environmental file!"); + return 1; + } + + if (cfg.database.name.empty() || cfg.database.user.empty() || + cfg.database.password.empty() || cfg.database.host.empty() || + cfg.database.port.empty()) { + bot::log::error("Main", + "DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT " + "must be set in environmental file!"); + return 1; + } + + bot::irc::Client client(cfg.twitch_credentials.client_id, + cfg.twitch_credentials.token); + bot::command::CommandLoader command_loader; + bot::loc::Localization localization("localization"); + bot::api::twitch::HelixClient helix_client(cfg.twitch_credentials.token, + cfg.twitch_credentials.client_id); + + client.join(client.get_bot_username()); + + pqxx::connection conn(GET_DATABASE_CONNECTION_URL(cfg)); + pqxx::work *work = new pqxx::work(conn); + + pqxx::result rows = work->exec( + "SELECT alias_id FROM channels WHERE opted_out_at is null AND alias_id " + "!= " + + std::to_string(client.get_bot_id())); + + std::vector<int> ids; + + for (const auto &row : rows) { + ids.push_back(row[0].as<int>()); + } + + auto helix_channels = helix_client.get_users(ids); + + // it could be optimized + for (const auto &helix_channel : helix_channels) { + auto channel = + work->exec("SELECT id, alias_name FROM channels WHERE alias_id = " + + std::to_string(helix_channel.id)); + + if (!channel.empty()) { + std::string name = channel[0][1].as<std::string>(); + + if (name != helix_channel.login) { + work->exec("UPDATE channels SET alias_name = '" + helix_channel.login + + "' WHERE id = " + std::to_string(channel[0][0].as<int>())); + work->commit(); + + delete work; + work = new pqxx::work(conn); + } + + client.join(helix_channel.login); + } + } + + work->commit(); + delete work; + + conn.close(); + + bot::stream::StreamListenerClient stream_listener_client(helix_client, client, + cfg); + + client.on<bot::irc::MessageType::Privmsg>( + [&client, &command_loader, &localization, &cfg, &helix_client]( + const bot::irc::Message<bot::irc::MessageType::Privmsg> &message) { + bot::InstanceBundle bundle{client, helix_client, localization, cfg}; + + pqxx::connection conn(GET_DATABASE_CONNECTION_URL(cfg)); + + bot::handlers::handle_private_message(bundle, command_loader, message, + conn); + + conn.close(); + }); + + client.run(); + + std::vector<std::thread> threads; + threads.push_back(std::thread(bot::create_timer_thread, &client, &cfg)); + threads.push_back(std::thread(&bot::stream::StreamListenerClient::run, + &stream_listener_client)); + + for (auto &thread : threads) { + thread.join(); + } + + return 0; +} diff --git a/bot/src/modules/custom_command.hpp b/bot/src/modules/custom_command.hpp new file mode 100644 index 0000000..50b3692 --- /dev/null +++ b/bot/src/modules/custom_command.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../commands/response_error.hpp" + +namespace bot { + namespace mod { + class CustomCommand : public command::Command { + std::string get_name() const override { return "scmd"; } + + schemas::PermissionLevel get_permission_level() const override { + return schemas::PermissionLevel::MODERATOR; + } + + std::vector<std::string> get_subcommand_ids() const override { + return {"new", "remove"}; + } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!request.subcommand_id.has_value()) { + throw ResponseException<NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::SUBCOMMAND); + } + + const std::string &subcommand_id = request.subcommand_id.value(); + + if (!request.message.has_value()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::CommandArgument::NAME); + } + + const std::string &message = request.message.value(); + std::vector<std::string> s = utils::string::split_text(message, ' '); + + std::string name = s[0]; + s.erase(s.begin()); + + pqxx::work work(request.conn); + pqxx::result cmds = work.exec( + "SELECT id FROM custom_commands WHERE name = '" + name + + "' AND channel_id = " + std::to_string(request.channel.get_id())); + + if (subcommand_id == "new") { + if (!cmds.empty()) { + throw ResponseException<ResponseError::NAMESAKE_CREATION>( + request, bundle.localization, name); + } + + if (s.empty()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, + command::CommandArgument::MESSAGE); + } + + std::string m = utils::string::str(s.begin(), s.end(), ' '); + + work.exec( + "INSERT INTO custom_commands(channel_id, name, message) VALUES " + "(" + + std::to_string(request.channel.get_id()) + ", '" + name + + "', '" + m + "')"); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::CustomcommandNew, + {name}) + .value(); + } else if (subcommand_id == "remove") { + if (cmds.empty()) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, name); + } + + work.exec("DELETE FROM custom_commands WHERE id = " + + std::to_string(cmds[0][0].as<int>())); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::CustomcommandDelete, + {name}) + .value(); + } + + throw ResponseException<ResponseError::SOMETHING_WENT_WRONG>( + request, bundle.localization); + } + }; + } +} diff --git a/bot/src/modules/event.hpp b/bot/src/modules/event.hpp new file mode 100644 index 0000000..4242f07 --- /dev/null +++ b/bot/src/modules/event.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../commands/response_error.hpp" +#include "../schemas/stream.hpp" + +namespace bot { + namespace mod { + class Event : public command::Command { + std::string get_name() const override { return "event"; } + + schemas::PermissionLevel get_permission_level() const override { + return schemas::PermissionLevel::MODERATOR; + } + + std::vector<std::string> get_subcommand_ids() const override { + return {"on", "off"}; + } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!request.subcommand_id.has_value()) { + throw ResponseException<NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::SUBCOMMAND); + } + + const std::string &subcommand_id = request.subcommand_id.value(); + + if (!request.message.has_value()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::CommandArgument::TARGET); + } + + const std::string &message = request.message.value(); + std::vector<std::string> s = utils::string::split_text(message, ' '); + + std::string target; + schemas::EventType type; + + std::vector<std::string> target_and_type = + utils::string::split_text(s[0], ':'); + + if (target_and_type.size() != 2) { + throw ResponseException<ResponseError::INCORRECT_ARGUMENT>( + request, bundle.localization, s[0]); + } + + s.erase(s.begin()); + + target = target_and_type[0]; + type = schemas::string_to_event_type(target_and_type[1]); + + std::string t = target_and_type[0] + ":" + target_and_type[1]; + + auto channels = bundle.helix_client.get_users({target}); + api::twitch::schemas::User channel; + + if (channels.empty() && type != schemas::EventType::CUSTOM) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, t); + } + + pqxx::work work(request.conn); + std::string query; + + if (type != schemas::CUSTOM) { + channel = channels[0]; + + query = "SELECT id FROM events WHERE channel_id = " + + std::to_string(request.channel.get_id()) + + " AND target_alias_id = " + std::to_string(channel.id) + + " AND event_type = " + std::to_string(type); + } else { + query = "SELECT id FROM events WHERE channel_id = " + + std::to_string(request.channel.get_id()) + + " AND custom_alias_id = '" + target + + "' AND event_type = " + std::to_string(type); + } + + pqxx::result event = work.exec(query); + + if (subcommand_id == "on") { + if (!event.empty()) { + throw ResponseException<ResponseError::NAMESAKE_CREATION>( + request, bundle.localization, t); + } + + if (s.empty()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, + command::CommandArgument::MESSAGE); + } + + std::string m = utils::string::str(s.begin(), s.end(), ' '); + + if (type != schemas::CUSTOM) { + query = + "INSERT INTO events (channel_id, target_alias_id, " + "event_type, " + "message) VALUES (" + + std::to_string(request.channel.get_id()) + ", " + + std::to_string(channel.id) + ", " + std::to_string(type) + + ", '" + m + "')"; + } else { + query = + "INSERT INTO events (channel_id, custom_alias_id, " + "event_type, " + "message) VALUES (" + + std::to_string(request.channel.get_id()) + ", '" + target + + "', " + std::to_string(type) + ", '" + m + "')"; + } + + work.exec(query); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::EventOn, {t}) + .value(); + } else if (subcommand_id == "off") { + if (event.empty()) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, t); + } + + work.exec("DELETE FROM events WHERE id = " + + std::to_string(event[0][0].as<int>())); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::EventOff, {t}) + .value(); + } + + throw ResponseException<ResponseError::SOMETHING_WENT_WRONG>( + request, bundle.localization); + } + }; + } +} diff --git a/bot/src/modules/help.hpp b/bot/src/modules/help.hpp new file mode 100644 index 0000000..13af228 --- /dev/null +++ b/bot/src/modules/help.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../commands/response_error.hpp" + +namespace bot { + namespace mod { + class Help : public command::Command { + std::string get_name() const override { return "help"; } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!bundle.configuration.url.help.has_value()) { + throw ResponseException<ResponseError::ILLEGAL_COMMAND>( + request, bundle.localization); + } + + return bundle.localization + .get_formatted_line(request, loc::LineId::HelpResponse, + {*bundle.configuration.url.help}) + .value(); + } + }; + } +} diff --git a/bot/src/modules/join.hpp b/bot/src/modules/join.hpp new file mode 100644 index 0000000..16e8b4a --- /dev/null +++ b/bot/src/modules/join.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include <pqxx/pqxx> +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../schemas/channel.hpp" + +namespace bot { + namespace mod { + class Join : public command::Command { + std::string get_name() const override { return "join"; } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!bundle.configuration.commands.join_allowed) { + std::string owner = ""; + + if (bundle.configuration.owner.name.has_value()) { + owner = " " + bundle.localization + .get_formatted_line( + request, loc::LineId::MsgOwner, + {*bundle.configuration.owner.name}) + .value(); + } + + return bundle.localization + .get_formatted_line(request, loc::LineId::JoinNotAllowed, + {owner}) + .value(); + } + + if (!bundle.configuration.commands.join_allow_from_other_chats && + request.channel.get_alias_name() != + bundle.irc_client.get_bot_username()) { + return bundle.localization + .get_formatted_line(request, loc::LineId::JoinFromOtherChat, + {bundle.irc_client.get_bot_username()}) + .value(); + } + + pqxx::work work(request.conn); + + pqxx::result channels = + work.exec("SELECT * FROM channels WHERE alias_id = " + + std::to_string(request.user.get_alias_id())); + + if (!channels.empty()) { + schemas::Channel channel(channels[0]); + + if (channel.get_opted_out_at().has_value()) { + work.exec("UPDATE channels SET opted_out_at = null WHERE id = " + + std::to_string(channel.get_id())); + work.commit(); + + bundle.irc_client.join(channel.get_alias_name()); + + return bundle.localization + .get_formatted_line(request, loc::LineId::JoinRejoined, {}) + .value(); + } + + return bundle.localization + .get_formatted_line(request, loc::LineId::JoinAlreadyIn, {}) + .value(); + } + + work.exec("INSERT INTO channels(alias_id, alias_name) VALUES (" + + std::to_string(request.user.get_alias_id()) + ", '" + + request.user.get_alias_name() + "')"); + work.commit(); + + bundle.irc_client.join(request.user.get_alias_name()); + bundle.irc_client.say( + request.user.get_alias_name(), + bundle.localization + .get_formatted_line(request, loc::LineId::JoinResponseInChat, + {}) + .value()); + + return bundle.localization + .get_formatted_line(request, loc::LineId::JoinResponse, {}) + .value(); + } + }; + } +} diff --git a/bot/src/modules/massping.hpp b/bot/src/modules/massping.hpp new file mode 100644 index 0000000..2957e34 --- /dev/null +++ b/bot/src/modules/massping.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" + +namespace bot { + namespace mod { + class Massping : public command::Command { + std::string get_name() const override { return "massping"; } + + schemas::PermissionLevel get_permission_level() const override { + return schemas::PermissionLevel::MODERATOR; + } + + int get_delay_seconds() const override { return 1; } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + auto chatters = bundle.helix_client.get_chatters( + request.channel.get_alias_id(), bundle.irc_client.get_bot_id()); + + std::string m; + + if (request.message.has_value()) { + m = request.message.value() + " ·"; + } + + std::string base = "📣 " + m + " "; + std::vector<std::string> msgs = {""}; + int index = 0; + + for (const auto &chatter : chatters) { + const std::string ¤t_msg = msgs.at(index); + std::string x = "@" + chatter.login; + + if (base.length() + current_msg.length() + 1 + x.length() >= 500) { + index += 1; + } + + if (index > msgs.size() - 1) { + msgs.push_back(x); + } else { + msgs[index] = current_msg + " " + x; + } + } + + std::vector<std::string> msgs2; + + for (const auto &m : msgs) { + msgs2.push_back(base + m); + } + + return msgs2; + } + }; + } +} diff --git a/bot/src/modules/notify.hpp b/bot/src/modules/notify.hpp new file mode 100644 index 0000000..3587e73 --- /dev/null +++ b/bot/src/modules/notify.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../commands/response_error.hpp" +#include "../schemas/stream.hpp" + +namespace bot { + namespace mod { + class Notify : public command::Command { + std::string get_name() const override { return "notify"; } + + std::vector<std::string> get_subcommand_ids() const override { + return {"sub", "unsub"}; + } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!request.subcommand_id.has_value()) { + throw ResponseException<NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::SUBCOMMAND); + } + + const std::string &subcommand_id = request.subcommand_id.value(); + + if (!request.message.has_value()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::CommandArgument::TARGET); + } + + const std::string &message = request.message.value(); + std::vector<std::string> s = utils::string::split_text(message, ' '); + + std::string target; + schemas::EventType type; + + std::vector<std::string> target_and_type = + utils::string::split_text(s[0], ':'); + + if (target_and_type.size() != 2) { + throw ResponseException<ResponseError::INCORRECT_ARGUMENT>( + request, bundle.localization, s[0]); + } + + s.erase(s.begin()); + + target = target_and_type[0]; + type = schemas::string_to_event_type(target_and_type[1]); + + std::string t = target_and_type[0] + ":" + target_and_type[1]; + + auto channels = bundle.helix_client.get_users({target}); + api::twitch::schemas::User channel; + + if (channels.empty() && type != schemas::EventType::CUSTOM) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, t); + } + + pqxx::work work(request.conn); + std::string query; + + if (type != schemas::CUSTOM) { + channel = channels[0]; + + query = "SELECT id FROM events WHERE channel_id = " + + std::to_string(request.channel.get_id()) + + " AND target_alias_id = " + std::to_string(channel.id) + + " AND event_type = " + std::to_string(type); + } else { + query = "SELECT id FROM events WHERE channel_id = " + + std::to_string(request.channel.get_id()) + + " AND custom_alias_id = '" + target + + "' AND event_type = " + std::to_string(type); + } + + pqxx::result events = work.exec(query); + + if (events.empty()) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, t); + } + + pqxx::row event = events[0]; + + pqxx::result subs = + work.exec("SELECT id FROM event_subscriptions WHERE event_id = " + + std::to_string(event[0].as<int>()) + " AND user_id = " + + std::to_string(request.user.get_id())); + + if (subcommand_id == "sub") { + if (!subs.empty()) { + throw ResponseException<ResponseError::NAMESAKE_CREATION>( + request, bundle.localization, t); + } + + work.exec( + "INSERT INTO event_subscriptions(event_id, user_id) VALUES (" + + std::to_string(event[0].as<int>()) + ", " + + std::to_string(request.user.get_id())); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::NotifySub, {t}) + .value(); + } else if (subcommand_id == "unsub") { + if (subs.empty()) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, t); + } + + work.exec("DELETE FROM event_subscriptions WHERE id = " + + std::to_string(subs[0][0].as<int>())); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::NotifyUnsub, {t}) + .value(); + } + + throw ResponseException<ResponseError::SOMETHING_WENT_WRONG>( + request, bundle.localization); + } + }; + } +} diff --git a/bot/src/modules/ping.hpp b/bot/src/modules/ping.hpp new file mode 100644 index 0000000..836917d --- /dev/null +++ b/bot/src/modules/ping.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include <sys/resource.h> +#include <sys/types.h> +#include <unistd.h> + +#include <chrono> +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../utils/chrono.hpp" + +namespace bot { + namespace mod { + class Ping : public command::Command { + std::string get_name() const override { return "ping"; } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + auto now = std::chrono::steady_clock::now(); + auto duration = now - START_TIME; + auto seconds = + std::chrono::duration_cast<std::chrono::seconds>(duration); + std::string uptime = utils::chrono::format_timestamp(seconds.count()); + + struct rusage usage; + getrusage(RUSAGE_SELF, &usage); + + int used_memory = usage.ru_maxrss / 1024; + + std::string cpp_info; + +#ifdef __cplusplus + cpp_info.append("C++" + std::to_string(__cplusplus).substr(2, 2)); +#endif + +#ifdef __VERSION__ + cpp_info.append(" (gcc " + + bot::utils::string::split_text(__VERSION__, ' ')[0] + + ")"); +#endif + + if (!cpp_info.empty()) { + cpp_info.append(" · "); + } + + return bundle.localization + .get_formatted_line( + request, loc::LineId::PingResponse, + {cpp_info, uptime, std::to_string(used_memory)}) + .value(); + } + }; + } +} diff --git a/bot/src/modules/timer.hpp b/bot/src/modules/timer.hpp new file mode 100644 index 0000000..36c3982 --- /dev/null +++ b/bot/src/modules/timer.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include <string> +#include <variant> +#include <vector> + +#include "../bundle.hpp" +#include "../commands/command.hpp" +#include "../commands/response_error.hpp" + +namespace bot { + namespace mod { + class Timer : public command::Command { + std::string get_name() const override { return "timer"; } + + schemas::PermissionLevel get_permission_level() const override { + return schemas::PermissionLevel::MODERATOR; + } + + std::vector<std::string> get_subcommand_ids() const override { + return {"new", "remove"}; + } + + std::variant<std::vector<std::string>, std::string> run( + const InstanceBundle &bundle, + const command::Request &request) const override { + if (!request.subcommand_id.has_value()) { + throw ResponseException<NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::SUBCOMMAND); + } + + const std::string &subcommand_id = request.subcommand_id.value(); + + if (!request.message.has_value()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, command::CommandArgument::NAME); + } + + const std::string &message = request.message.value(); + std::vector<std::string> s = utils::string::split_text(message, ' '); + + std::string name = s[0]; + s.erase(s.begin()); + + pqxx::work work(request.conn); + pqxx::result timers = work.exec( + "SELECT id FROM timers WHERE name = '" + name + + "' AND channel_id = " + std::to_string(request.channel.get_id())); + + if (subcommand_id == "new") { + if (!timers.empty()) { + throw ResponseException<ResponseError::NAMESAKE_CREATION>( + request, bundle.localization, name); + } + + if (s.empty()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, + command::CommandArgument::INTERVAL); + } + + int interval_s; + + try { + interval_s = std::stoi(s[0]); + } catch (std::exception e) { + throw ResponseException<ResponseError::INCORRECT_ARGUMENT>( + request, bundle.localization, s[0]); + } + + s.erase(s.begin()); + + if (s.empty()) { + throw ResponseException<ResponseError::NOT_ENOUGH_ARGUMENTS>( + request, bundle.localization, + command::CommandArgument::MESSAGE); + } + + std::string m = utils::string::str(s.begin(), s.end(), ' '); + + work.exec( + "INSERT INTO timers(channel_id, name, message, interval_sec) " + "VALUES " + "(" + + std::to_string(request.channel.get_id()) + ", '" + name + + "', '" + m + "', " + std::to_string(interval_s) + ")"); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::TimerNew, {name}) + .value(); + } else if (subcommand_id == "remove") { + if (timers.empty()) { + throw ResponseException<ResponseError::NOT_FOUND>( + request, bundle.localization, name); + } + + work.exec("DELETE FROM timers WHERE id = " + + std::to_string(timers[0][0].as<int>())); + work.commit(); + + return bundle.localization + .get_formatted_line(request, loc::LineId::TimerDelete, {name}) + .value(); + } + + throw ResponseException<ResponseError::SOMETHING_WENT_WRONG>( + request, bundle.localization); + } + }; + } +} diff --git a/bot/src/schemas/channel.hpp b/bot/src/schemas/channel.hpp new file mode 100644 index 0000000..2560331 --- /dev/null +++ b/bot/src/schemas/channel.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include <chrono> +#include <optional> +#include <pqxx/pqxx> +#include <string> + +#include "../constants.hpp" +#include "../utils/chrono.hpp" + +namespace bot::schemas { + class Channel { + public: + Channel(const pqxx::row &row) { + this->id = row[0].as<int>(); + this->alias_id = row[1].as<int>(); + this->alias_name = row[2].as<std::string>(); + + this->joined_at = + utils::chrono::string_to_time_point(row[3].as<std::string>()); + + if (!row[4].is_null()) { + this->opted_out_at = + utils::chrono::string_to_time_point(row[4].as<std::string>()); + } + } + + ~Channel() = default; + + const int &get_id() const { return this->id; } + const int &get_alias_id() const { return this->alias_id; } + const std::string &get_alias_name() const { return this->alias_name; } + const std::chrono::system_clock::time_point &get_joined_at() const { + return this->joined_at; + } + const std::optional<std::chrono::system_clock::time_point> & + get_opted_out_at() const { + return this->opted_out_at; + } + + private: + int id, alias_id; + std::string alias_name; + std::chrono::system_clock::time_point joined_at; + std::optional<std::chrono::system_clock::time_point> opted_out_at; + }; + + class ChannelPreferences { + public: + ChannelPreferences(const pqxx::row &row) { + this->channel_id = row[0].as<int>(); + + if (!row[2].is_null()) { + this->prefix = row[1].as<std::string>(); + } else { + this->prefix = DEFAULT_PREFIX; + } + + if (!row[3].is_null()) { + this->locale = row[2].as<std::string>(); + } else { + this->locale = DEFAULT_LOCALE_ID; + } + } + + ~ChannelPreferences() = default; + + const int &get_channel_id() const { return this->channel_id; } + const std::string &get_prefix() const { return this->prefix; } + const std::string &get_locale() const { return this->locale; } + + private: + int channel_id; + std::string prefix, locale; + }; +} diff --git a/bot/src/schemas/stream.cpp b/bot/src/schemas/stream.cpp new file mode 100644 index 0000000..6ef10dc --- /dev/null +++ b/bot/src/schemas/stream.cpp @@ -0,0 +1,17 @@ +#include "stream.hpp" + +namespace bot::schemas { + EventType string_to_event_type(const std::string &type) { + if (type == "live") { + return EventType::LIVE; + } else if (type == "offline") { + return EventType::OFFLINE; + } else if (type == "title") { + return EventType::TITLE; + } else if (type == "game") { + return EventType::GAME; + } else { + return EventType::CUSTOM; + } + } +} diff --git a/bot/src/schemas/stream.hpp b/bot/src/schemas/stream.hpp new file mode 100644 index 0000000..a636ea5 --- /dev/null +++ b/bot/src/schemas/stream.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include <string> + +namespace bot::schemas { + enum EventType { LIVE, OFFLINE, TITLE, GAME, CUSTOM = 99 }; + EventType string_to_event_type(const std::string &type); + + enum EventFlag { MASSPING }; + +} diff --git a/bot/src/schemas/user.hpp b/bot/src/schemas/user.hpp new file mode 100644 index 0000000..0bd1368 --- /dev/null +++ b/bot/src/schemas/user.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include <chrono> +#include <optional> +#include <pqxx/pqxx> +#include <string> + +#include "../utils/chrono.hpp" + +namespace bot::schemas { + class User { + public: + User(const pqxx::row &row) { + this->id = row[0].as<int>(); + this->alias_id = row[1].as<int>(); + this->alias_name = row[2].as<std::string>(); + + this->joined_at = + utils::chrono::string_to_time_point(row[3].as<std::string>()); + + if (!row[4].is_null()) { + this->opted_out_at = + utils::chrono::string_to_time_point(row[4].as<std::string>()); + } + } + + ~User() = default; + + const int &get_id() const { return this->id; } + const int &get_alias_id() const { return this->alias_id; } + const std::string &get_alias_name() const { return this->alias_name; } + void set_alias_name(const std::string &alias_name) { + this->alias_name = alias_name; + } + const std::chrono::system_clock::time_point &get_joined_at() const { + return this->joined_at; + } + const std::optional<std::chrono::system_clock::time_point> & + get_opted_out_at() const { + return this->opted_out_at; + } + + private: + int id, alias_id; + std::string alias_name; + std::chrono::system_clock::time_point joined_at; + std::optional<std::chrono::system_clock::time_point> opted_out_at; + }; + + enum PermissionLevel { SUSPENDED, USER, VIP, MODERATOR, BROADCASTER }; + + class UserRights { + public: + UserRights(const pqxx::row &row) { + this->id = row[0].as<int>(); + this->user_id = row[1].as<int>(); + this->channel_id = row[2].as<int>(); + this->level = static_cast<PermissionLevel>(row[3].as<int>()); + } + + ~UserRights() = default; + + const int &get_id() const { return this->id; } + const int &get_user_id() const { return this->user_id; } + const int &get_channel_id() const { return this->channel_id; } + const PermissionLevel &get_level() const { return this->level; } + void set_level(PermissionLevel level) { this->level = level; } + + private: + int id, user_id, channel_id; + PermissionLevel level; + }; +} diff --git a/bot/src/stream.cpp b/bot/src/stream.cpp new file mode 100644 index 0000000..6e48fb8 --- /dev/null +++ b/bot/src/stream.cpp @@ -0,0 +1,200 @@ +#include "stream.hpp" + +#include <algorithm> +#include <chrono> +#include <pqxx/pqxx> +#include <set> +#include <string> +#include <thread> +#include <utility> +#include <vector> + +#include "api/twitch/schemas/stream.hpp" +#include "config.hpp" +#include "logger.hpp" +#include "schemas/stream.hpp" +#include "utils/string.hpp" + +namespace bot::stream { + void StreamListenerClient::listen_channel(const int &id) { + this->ids.push_back(id); + } + void StreamListenerClient::unlisten_channel(const int &id) { + auto x = std::find_if(this->ids.begin(), this->ids.end(), + [&](const auto &x) { return x == id; }); + + if (x != this->ids.end()) { + this->ids.erase(x); + } + + auto y = std::find_if(this->online_ids.begin(), this->online_ids.end(), + [&](const auto &x) { return x == id; }); + + if (y != this->online_ids.end()) { + this->online_ids.erase(y); + } + } + void StreamListenerClient::run() { + while (true) { + this->update_channel_ids(); + this->check(); + std::this_thread::sleep_for(std::chrono::seconds(5)); + } + } + void StreamListenerClient::check() { + auto streams = this->helix_client.get_streams(this->ids); + auto now = std::chrono::system_clock::now(); + auto now_time_it = std::chrono::system_clock::to_time_t(now); + auto now_tm = std::gmtime(&now_time_it); + now = std::chrono::system_clock::from_time_t(std::mktime(now_tm)); + + // adding new ids + for (const auto &stream : streams) { + bool is_already_live = + std::any_of(this->online_ids.begin(), this->online_ids.end(), + [&](const auto &x) { return x == stream.get_user_id(); }); + + if (!is_already_live) { + this->online_ids.insert(stream.get_user_id()); + + auto difference = now - stream.get_started_at(); + auto difference_min = + std::chrono::duration_cast<std::chrono::minutes>(difference); + + if (difference_min.count() < 1) { + this->handler(schemas::EventType::LIVE, stream); + } + } + } + + // removing old ids + for (auto i = this->online_ids.begin(); i != this->online_ids.end();) { + auto stream = + std::find_if(streams.begin(), streams.end(), + [&](const auto &x) { return x.get_user_id() == *i; }); + + if (stream == streams.end()) { + this->handler(schemas::EventType::OFFLINE, + api::twitch::schemas::Stream{*i}); + i = this->online_ids.erase(i); + } else { + ++i; + } + } + } + void StreamListenerClient::handler( + const schemas::EventType &type, + const api::twitch::schemas::Stream &stream) { + pqxx::connection conn(GET_DATABASE_CONNECTION_URL(this->configuration)); + pqxx::work work(conn); + + pqxx::result events = work.exec( + "SELECT id, channel_id, message, flags FROM events WHERE event_type " + "= " + + std::to_string(type) + + " AND target_alias_id = " + std::to_string(stream.get_user_id())); + + for (const auto &event : events) { + pqxx::row channel = work.exec1( + "SELECT alias_id, alias_name, opted_out_at FROM channels WHERE id " + "= " + + std::to_string(event[1].as<int>())); + + if (!channel[2].is_null()) { + continue; + } + + pqxx::result subs = work.exec( + "SELECT user_id FROM event_subscriptions WHERE event_id = " + + std::to_string(event[0].as<int>())); + + std::set<std::string> user_ids; + if (!subs.empty()) { + for (const auto &sub : subs) { + user_ids.insert(std::to_string(sub[0].as<int>())); + } + + pqxx::result users = work.exec( + "SELECT alias_name FROM users WHERE id IN (" + + utils::string::str(user_ids.begin(), user_ids.end(), ',') + ")"); + + user_ids.clear(); + + for (const auto &user : users) { + user_ids.insert(user[0].as<std::string>()); + } + } + + auto flags = event[3].as_array(); + std::pair<pqxx::array_parser::juncture, std::string> elem; + + do { + elem = flags.get_next(); + if (elem.first == pqxx::array_parser::juncture::string_value) { + if (std::stoi(elem.second) == schemas::EventFlag::MASSPING) { + auto chatters = this->helix_client.get_chatters( + channel[0].as<int>(), this->irc_client.get_bot_id()); + + for (const auto &chatter : chatters) { + user_ids.insert(chatter.login); + } + } + } + } while (elem.first != pqxx::array_parser::juncture::done); + + std::string base = "⚡ " + event[2].as<std::string>(); + std::vector<std::string> msgs = {""}; + int index = 0; + + if (!user_ids.empty()) { + base.append(" · "); + } + + for (const auto &user_id : user_ids) { + const std::string ¤t_msg = msgs.at(index); + std::string x = "@" + user_id; + + if (base.length() + current_msg.length() + 1 + x.length() >= 500) { + index += 1; + } + + if (index > msgs.size() - 1) { + msgs.push_back(x); + } else { + msgs[index] = current_msg + " " + x; + } + } + + for (const auto &msg : msgs) { + this->irc_client.say(channel[1].as<std::string>(), base + msg); + } + } + + work.commit(); + conn.close(); + } + void StreamListenerClient::update_channel_ids() { + pqxx::connection conn(GET_DATABASE_CONNECTION_URL(this->configuration)); + pqxx::work work(conn); + + pqxx::result ids = + work.exec("SELECT target_alias_id FROM events WHERE event_type < 99"); + + for (const auto &row : ids) { + int id = row[0].as<int>(); + + if (std::any_of(this->ids.begin(), this->ids.end(), + [&](const auto &x) { return x == id; })) { + continue; + } + + log::info("TwitchStreamListener", + "Listening stream events for ID " + std::to_string(id)); + + this->ids.push_back(id); + } + + work.commit(); + conn.close(); + } +} diff --git a/bot/src/stream.hpp b/bot/src/stream.hpp new file mode 100644 index 0000000..73313ed --- /dev/null +++ b/bot/src/stream.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include <set> +#include <vector> + +#include "api/twitch/helix_client.hpp" +#include "api/twitch/schemas/stream.hpp" +#include "config.hpp" +#include "irc/client.hpp" +#include "schemas/stream.hpp" + +namespace bot::stream { + class StreamListenerClient { + public: + StreamListenerClient(const api::twitch::HelixClient &helix_client, + irc::Client &irc_client, + const Configuration &configuration) + : helix_client(helix_client), + irc_client(irc_client), + configuration(configuration){}; + ~StreamListenerClient() = default; + + void run(); + void listen_channel(const int &id); + void unlisten_channel(const int &id); + + private: + void check(); + void handler(const schemas::EventType &type, + const api::twitch::schemas::Stream &stream); + void update_channel_ids(); + + const api::twitch::HelixClient &helix_client; + irc::Client &irc_client; + const Configuration &configuration; + + std::vector<int> ids; + + std::set<int> online_ids; + }; +} diff --git a/bot/src/timer.cpp b/bot/src/timer.cpp new file mode 100644 index 0000000..055dde0 --- /dev/null +++ b/bot/src/timer.cpp @@ -0,0 +1,70 @@ +#include "timer.hpp" + +#include <chrono> +#include <pqxx/pqxx> +#include <string> +#include <thread> + +#include "config.hpp" +#include "irc/client.hpp" +#include "utils/chrono.hpp" + +namespace bot { + void create_timer_thread(irc::Client *irc_client, + Configuration *configuration) { + while (true) { + pqxx::connection conn(GET_DATABASE_CONNECTION_URL_POINTER(configuration)); + pqxx::work *work = new pqxx::work(conn); + + pqxx::result timers = work->exec( + "SELECT id, interval_sec, message, channel_id, last_executed_at FROM " + "timers"); + + for (const auto &timer : timers) { + int id = timer[0].as<int>(); + int interval_sec = timer[1].as<int>(); + std::string message = timer[2].as<std::string>(); + int channel_id = timer[3].as<int>(); + + // it could be done in sql query + std::chrono::system_clock::time_point last_executed_at = + utils::chrono::string_to_time_point(timer[4].as<std::string>()); + auto now = std::chrono::system_clock::now(); + auto now_time_it = std::chrono::system_clock::to_time_t(now); + auto now_tm = std::gmtime(&now_time_it); + now = std::chrono::system_clock::from_time_t(std::mktime(now_tm)); + + auto difference = std::chrono::duration_cast<std::chrono::seconds>( + now - last_executed_at); + + if (difference.count() > interval_sec) { + pqxx::result channels = work->exec( + "SELECT alias_name, opted_out_at FROM channels WHERE id = " + + std::to_string(channel_id)); + + if (!channels.empty() && channels[0][1].is_null()) { + std::string alias_name = channels[0][0].as<std::string>(); + + irc_client->say(alias_name, message); + } + + work->exec( + "UPDATE timers SET last_executed_at = timezone('utc', now()) " + "WHERE " + "id = " + + std::to_string(id)); + + work->commit(); + + delete work; + work = new pqxx::work(conn); + } + } + + delete work; + conn.close(); + + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + } +} diff --git a/bot/src/timer.hpp b/bot/src/timer.hpp new file mode 100644 index 0000000..40a52ee --- /dev/null +++ b/bot/src/timer.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "config.hpp" +#include "irc/client.hpp" + +namespace bot { + void create_timer_thread(irc::Client *irc_client, + Configuration *configuration); +} diff --git a/bot/src/utils/chrono.cpp b/bot/src/utils/chrono.cpp new file mode 100644 index 0000000..7a7f2c9 --- /dev/null +++ b/bot/src/utils/chrono.cpp @@ -0,0 +1,48 @@ +#include "chrono.hpp" + +#include <chrono> +#include <cmath> +#include <ctime> +#include <iomanip> +#include <sstream> +#include <string> + +namespace bot::utils::chrono { + std::string format_timestamp(int seconds) { + int d = round(seconds / (60 * 60 * 24)); + int h = round(seconds / (60 * 60) % 24); + int m = round(seconds % (60 * 60) / 60); + int s = round(seconds % 60); + + // Only seconds: + if (d == 0 && h == 0 && m == 0) { + return std::to_string(s) + "s"; + } + // Minutes and seconds: + else if (d == 0 && h == 0) { + return std::to_string(m) + "m" + std::to_string(s) + "s"; + } + // Hours and minutes: + else if (d == 0) { + return std::to_string(h) + "h" + std::to_string(m) + "m"; + } + // Days and hours: + else { + return std::to_string(d) + "d" + std::to_string(h) + "h"; + } + } + + std::chrono::system_clock::time_point string_to_time_point( + const std::string &value, const std::string &format) { + std::tm tm = {}; + std::stringstream ss(value); + + ss >> std::get_time(&tm, format.c_str()); + + if (ss.fail()) { + throw std::invalid_argument("Invalid time format"); + } + + return std::chrono::system_clock::from_time_t(std::mktime(&tm)); + } +} diff --git a/bot/src/utils/chrono.hpp b/bot/src/utils/chrono.hpp new file mode 100644 index 0000000..7e85e70 --- /dev/null +++ b/bot/src/utils/chrono.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include <chrono> +#include <string> + +namespace bot::utils::chrono { + std::string format_timestamp(int seconds); + std::chrono::system_clock::time_point string_to_time_point( + const std::string &value, + const std::string &format = "%Y-%m-%d %H:%M:%S"); +} diff --git a/bot/src/utils/string.cpp b/bot/src/utils/string.cpp new file mode 100644 index 0000000..71c06bf --- /dev/null +++ b/bot/src/utils/string.cpp @@ -0,0 +1,66 @@ +#include "string.hpp" + +#include <sstream> +#include <string> +#include <vector> + +namespace bot { + namespace utils { + namespace string { + std::vector<std::string> split_text(const std::string &text, + char delimiter) { + std::vector<std::string> parts; + + std::istringstream iss(text); + std::string part; + + while (std::getline(iss, part, delimiter)) { + parts.push_back(part); + } + + return parts; + } + + std::string join_vector(const std::vector<std::string> &vec, + char delimiter) { + if (vec.empty()) { + return ""; + } + + std::string str; + + for (auto i = vec.begin(); i != vec.end() - 1; i++) { + str += *i + delimiter; + } + + str += vec[vec.size() - 1]; + + return str; + } + + std::string join_vector(const std::vector<std::string> &vec) { + std::string str; + + for (const auto &e : vec) { + str += e; + } + + return str; + } + + bool string_contains_sql_injection(const std::string &input) { + std::string forbidden_strings[] = {";", "--", "'", "\"", + "/*", "*/", "xp_", "exec", + "sp_", "insert", "select", "delete"}; + + for (const auto &str : forbidden_strings) { + if (input.find(str) != std::string::npos) { + return true; + } + } + + return false; + } + } + } +} diff --git a/bot/src/utils/string.hpp b/bot/src/utils/string.hpp new file mode 100644 index 0000000..c8385ad --- /dev/null +++ b/bot/src/utils/string.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include <sstream> +#include <string> +#include <vector> + +namespace bot { + namespace utils { + namespace string { + std::vector<std::string> split_text(const std::string &text, + char delimiter); + std::string join_vector(const std::vector<std::string> &vec, + char delimiter); + std::string join_vector(const std::vector<std::string> &vec); + + template <typename T> + std::string str(T begin, T end, char delimiter) { + std::stringstream ss; + bool first = true; + + for (; begin != end; begin++) { + if (!first) ss << delimiter; + ss << *begin; + first = false; + } + return ss.str(); + } + + bool string_contains_sql_injection(const std::string &input); + } + } +} |
