summaryrefslogtreecommitdiff
path: root/bot/src
diff options
context:
space:
mode:
authorilotterytea <iltsu@alright.party>2024-05-18 14:48:12 +0500
committerilotterytea <iltsu@alright.party>2024-05-18 14:48:12 +0500
commitd1793df1eda463b10107d41785ad1d7f055ed476 (patch)
treefd3e41c3b4a05924748ae4b762e1ae55a0bc815c /bot/src
parentd7a2de17e9b7931f68b5b4079b1c36866a19d343 (diff)
upd: moved the bot part to a relative subfolder
Diffstat (limited to 'bot/src')
-rw-r--r--bot/src/api/twitch/helix_client.cpp144
-rw-r--r--bot/src/api/twitch/helix_client.hpp31
-rw-r--r--bot/src/api/twitch/schemas/stream.hpp35
-rw-r--r--bot/src/api/twitch/schemas/user.hpp10
-rw-r--r--bot/src/bundle.hpp15
-rw-r--r--bot/src/commands/command.cpp104
-rw-r--r--bot/src/commands/command.hpp55
-rw-r--r--bot/src/commands/request.hpp25
-rw-r--r--bot/src/commands/request_util.cpp205
-rw-r--r--bot/src/commands/request_util.hpp13
-rw-r--r--bot/src/commands/response_error.hpp222
-rw-r--r--bot/src/config.cpp84
-rw-r--r--bot/src/config.hpp54
-rw-r--r--bot/src/constants.hpp13
-rw-r--r--bot/src/handlers.cpp85
-rw-r--r--bot/src/handlers.hpp14
-rw-r--r--bot/src/irc/client.cpp155
-rw-r--r--bot/src/irc/client.hpp58
-rw-r--r--bot/src/irc/message.cpp30
-rw-r--r--bot/src/irc/message.hpp130
-rw-r--r--bot/src/localization/line_id.cpp100
-rw-r--r--bot/src/localization/line_id.hpp56
-rw-r--r--bot/src/localization/localization.cpp132
-rw-r--r--bot/src/localization/localization.hpp37
-rw-r--r--bot/src/logger.cpp96
-rw-r--r--bot/src/logger.hpp16
-rw-r--r--bot/src/main.cpp127
-rw-r--r--bot/src/modules/custom_command.hpp96
-rw-r--r--bot/src/modules/event.hpp145
-rw-r--r--bot/src/modules/help.hpp31
-rw-r--r--bot/src/modules/join.hpp91
-rw-r--r--bot/src/modules/massping.hpp62
-rw-r--r--bot/src/modules/notify.hpp131
-rw-r--r--bot/src/modules/ping.hpp59
-rw-r--r--bot/src/modules/timer.hpp112
-rw-r--r--bot/src/schemas/channel.hpp76
-rw-r--r--bot/src/schemas/stream.cpp17
-rw-r--r--bot/src/schemas/stream.hpp11
-rw-r--r--bot/src/schemas/user.hpp73
-rw-r--r--bot/src/stream.cpp200
-rw-r--r--bot/src/stream.hpp41
-rw-r--r--bot/src/timer.cpp70
-rw-r--r--bot/src/timer.hpp9
-rw-r--r--bot/src/utils/chrono.cpp48
-rw-r--r--bot/src/utils/chrono.hpp11
-rw-r--r--bot/src/utils/string.cpp66
-rw-r--r--bot/src/utils/string.hpp32
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(&current_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 &current_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 &current_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);
+ }
+ }
+}