diff options
Diffstat (limited to 'bot/src')
| -rw-r--r-- | bot/src/api/kick.cpp | 125 | ||||
| -rw-r--r-- | bot/src/api/kick.hpp | 39 | ||||
| -rw-r--r-- | bot/src/bundle.hpp | 2 | ||||
| -rw-r--r-- | bot/src/commands/lua.cpp | 25 | ||||
| -rw-r--r-- | bot/src/commands/lua.hpp | 2 | ||||
| -rw-r--r-- | bot/src/config.cpp | 8 | ||||
| -rw-r--r-- | bot/src/config.hpp | 5 | ||||
| -rw-r--r-- | bot/src/main.cpp | 17 | ||||
| -rw-r--r-- | bot/src/schemas/stream.cpp | 16 | ||||
| -rw-r--r-- | bot/src/schemas/stream.hpp | 4 | ||||
| -rw-r--r-- | bot/src/stream.cpp | 130 | ||||
| -rw-r--r-- | bot/src/stream.hpp | 10 |
12 files changed, 354 insertions, 29 deletions
diff --git a/bot/src/api/kick.cpp b/bot/src/api/kick.cpp new file mode 100644 index 0000000..9b64f48 --- /dev/null +++ b/bot/src/api/kick.cpp @@ -0,0 +1,125 @@ +#include "api/kick.hpp" + +#include <chrono> +#include <stdexcept> +#include <string> +#include <thread> +#include <vector> + +#include "cpr/api.h" +#include "cpr/cprtypes.h" +#include "cpr/response.h" +#include "logger.hpp" +#include "nlohmann/json.hpp" +#include "nlohmann/json_fwd.hpp" +#include "utils/chrono.hpp" +#include "utils/string.hpp" + +namespace bot::api { + std::vector<KickChannel> parse_json_channels(const nlohmann::json &value) { + if (!value.contains("data")) { + log::warn("api/kick", "No data object in Kick API get_channels response"); + return {}; + } + + std::vector<KickChannel> channels; + nlohmann::json d = value["data"]; + + for (const nlohmann::json &c : d) { + channels.push_back( + {c["broadcaster_user_id"], c["slug"], c["stream_title"], + c["category"]["name"], c["stream"]["is_live"], + utils::chrono::string_to_time_point(c["stream"]["start_time"], + "%Y-%m-%dT%H:%M:%SZ")}); + } + + return channels; + } + + std::vector<KickChannel> KickAPIClient::get_channels( + const std::vector<int> &ids) const { + if (this->authorization_key.empty()) { + log::error("api/kick", "You must be authorized before using Kick API"); + return {}; + } + + if (ids.empty()) { + return {}; + } + + cpr::Response r = cpr::Get( + cpr::Url{this->base_url + "/public/v1/channels?broadcaster_user_id=" + + utils::string::str(ids.begin(), ids.end(), ',')}, + cpr::Header{{"Authorization", "Bearer " + this->authorization_key}}); + + if (r.status_code != 200) { + log::error("api/kick", "Failed to get Kick channels. Status code: " + + std::to_string(r.status_code)); + return {}; + } + + nlohmann::json j = nlohmann::json::parse(r.text); + return parse_json_channels(j); + } + + std::vector<KickChannel> KickAPIClient::get_channels( + const std::string &slug) const { + if (this->authorization_key.empty()) { + log::error("api/kick", "You must be authorized before using Kick API"); + return {}; + } + + if (slug.empty()) { + return {}; + } + + cpr::Response r = cpr::Get( + cpr::Url{this->base_url + "/public/v1/channels?slug=" + slug}, + cpr::Header{{"Authorization", "Bearer " + this->authorization_key}}); + + if (r.status_code != 200) { + log::error("api/kick", "Failed to get Kick channels. Status code: " + + std::to_string(r.status_code)); + return {}; + } + + nlohmann::json j = nlohmann::json::parse(r.text); + return parse_json_channels(j); + } + + void KickAPIClient::authorize() { + cpr::Response r = cpr::Post( + cpr::Url{"https://id.kick.com/oauth/" + "token?grant_type=client_credentials&client_id=" + + this->client_id + "&client_secret=" + this->client_secret}); + + if (r.status_code != 200) { + throw std::runtime_error( + "Failed to authorize in Kick API. Status code: " + + std::to_string(r.status_code)); + } + + nlohmann::json j = nlohmann::json::parse(r.text); + + this->authorization_key = j["access_token"]; + this->expires_in = j["expires_in"]; + this->token_acquired_timestamp = std::time(nullptr); + + log::info("api/kick", "Successfully authorized in Kick API!"); + } + + void KickAPIClient::refresh_token_thread() { + while (true) { + std::this_thread::sleep_for(std::chrono::seconds(60)); + if (this->client_id.empty() || this->client_secret.empty()) { + break; + } else if (std::time(nullptr) - this->token_acquired_timestamp < + this->expires_in - 300) { + continue; + } + + log::info("api/kick", "Kick token is going to expire. Refreshing..."); + this->authorize(); + } + } +}
\ No newline at end of file diff --git a/bot/src/api/kick.hpp b/bot/src/api/kick.hpp new file mode 100644 index 0000000..9b29b24 --- /dev/null +++ b/bot/src/api/kick.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include <chrono> +#include <string> +#include <vector> + +namespace bot::api { + struct KickChannel { + int broadcaster_user_id; + std::string slug, stream_title, stream_game_name; + bool is_live; + std::chrono::system_clock::time_point start_time; + }; + + class KickAPIClient { + public: + KickAPIClient(const std::string &client_id, + const std::string &client_secret) + : client_id(client_id), client_secret(client_secret) { + this->authorize(); + }; + + ~KickAPIClient() = default; + + std::vector<KickChannel> get_channels(const std::vector<int> &ids) const; + std::vector<KickChannel> get_channels(const std::string &slug) const; + + void refresh_token_thread(); + + private: + void authorize(); + + std::string authorization_key; + int expires_in; + long token_acquired_timestamp; + const std::string base_url = "https://api.kick.com", client_id, + client_secret; + }; +}
\ No newline at end of file diff --git a/bot/src/bundle.hpp b/bot/src/bundle.hpp index 6f29a50..a343416 100644 --- a/bot/src/bundle.hpp +++ b/bot/src/bundle.hpp @@ -8,6 +8,7 @@ namespace bot { class InstanceBundle; } +#include "api/kick.hpp" #include "api/twitch/helix_client.hpp" #include "commands/command.hpp" #include "config.hpp" @@ -18,6 +19,7 @@ namespace bot { struct InstanceBundle { irc::Client &irc_client; const api::twitch::HelixClient &helix_client; + const api::KickAPIClient &kick_api_client; const bot::loc::Localization &localization; const Configuration &configuration; const command::CommandLoader &command_loader; diff --git a/bot/src/commands/lua.cpp b/bot/src/commands/lua.cpp index 90b734e..cb00887 100644 --- a/bot/src/commands/lua.cpp +++ b/bot/src/commands/lua.cpp @@ -17,6 +17,7 @@ #include <string> #include <vector> +#include "api/kick.hpp" #include "api/twitch/schemas/user.hpp" #include "bundle.hpp" #include "commands/request.hpp" @@ -610,6 +611,7 @@ namespace bot::command::lua { lua::library::add_bot_library(state, bundle); lua::library::add_irc_library(state, bundle); lua::library::add_twitch_library(state, request, bundle); + lua::library::add_kick_library(state, bundle); lua::library::add_db_library(state, bundle.configuration); lua::library::add_l10n_library(state, bundle); } @@ -702,6 +704,29 @@ namespace bot::command::lua { return o; }); } + + void add_kick_library(std::shared_ptr<sol::state> state, + const InstanceBundle &bundle) { + state->set_function( + "kick_get_channels", [state, &bundle](const std::string &slug) { + std::vector<api::KickChannel> channels = + bundle.kick_api_client.get_channels(slug); + + sol::table o = state->create_table(); + + std::for_each(channels.begin(), channels.end(), + [state, &o](const api::KickChannel &x) { + sol::table u = state->create_table(); + + u["id"] = x.broadcaster_user_id; + u["login"] = x.slug; + + o.add(u); + }); + + return o; + }); + } } Response parse_lua_response(const sol::table &r, sol::object &res, diff --git a/bot/src/commands/lua.hpp b/bot/src/commands/lua.hpp index 3d514f3..bde317b 100644 --- a/bot/src/commands/lua.hpp +++ b/bot/src/commands/lua.hpp @@ -28,6 +28,8 @@ namespace bot::command::lua { void add_twitch_library(std::shared_ptr<sol::state> state, const Request &request, const InstanceBundle &bundle); + void add_kick_library(std::shared_ptr<sol::state> state, + const InstanceBundle &bundle); void add_net_library(std::shared_ptr<sol::state> state); void add_db_library(std::shared_ptr<sol::state> state, const Configuration &config); diff --git a/bot/src/config.cpp b/bot/src/config.cpp index 4fc3994..9f22edf 100644 --- a/bot/src/config.cpp +++ b/bot/src/config.cpp @@ -81,6 +81,7 @@ namespace bot { Configuration cfg; TwitchCredentialsConfiguration ttv_crd_cfg; + KickCredentialsConfiguration kick_crd_cfg; DatabaseConfiguration db_cfg; CommandConfiguration cmd_cfg; OwnerConfiguration owner_cfg; @@ -116,6 +117,12 @@ namespace bot { db_cfg.port = value; } + else if (key == "kick.client_id") { + kick_crd_cfg.client_id = value; + } else if (key == "kick.client_secret") { + kick_crd_cfg.client_secret = value; + } + else if (key == "commands.join_allowed") { cmd_cfg.join_allowed = std::stoi(value); } else if (key == "commands.join_allow_from_other_chats") { @@ -153,6 +160,7 @@ namespace bot { cfg.owner = owner_cfg; cfg.commands = cmd_cfg; cfg.twitch_credentials = ttv_crd_cfg; + cfg.kick_credentials = kick_crd_cfg; cfg.database = db_cfg; cfg.tokens = token_cfg; diff --git a/bot/src/config.hpp b/bot/src/config.hpp index 1e918b6..f6bba7a 100644 --- a/bot/src/config.hpp +++ b/bot/src/config.hpp @@ -28,6 +28,10 @@ namespace bot { std::string token; }; + struct KickCredentialsConfiguration { + std::string client_id, client_secret; + }; + struct CommandConfiguration { bool join_allowed = true; bool join_allow_from_other_chats = false; @@ -56,6 +60,7 @@ namespace bot { struct Configuration { TwitchCredentialsConfiguration twitch_credentials; + KickCredentialsConfiguration kick_credentials; DatabaseConfiguration database; CommandConfiguration commands; OwnerConfiguration owner; diff --git a/bot/src/main.cpp b/bot/src/main.cpp index c98841b..fe0d3d1 100644 --- a/bot/src/main.cpp +++ b/bot/src/main.cpp @@ -7,6 +7,7 @@ #include <thread> #include <vector> +#include "api/kick.hpp" #include "api/twitch/helix_client.hpp" #include "bundle.hpp" #include "commands/command.hpp" @@ -62,6 +63,9 @@ int main(int argc, char *argv[]) { bot::api::twitch::HelixClient helix_client(cfg.twitch_credentials.token, cfg.twitch_credentials.client_id); + bot::api::KickAPIClient kick_api_client(cfg.kick_credentials.client_id, + cfg.kick_credentials.client_secret); + #ifdef BUILD_BETTERTTV emotespp::BetterTTVWebsocketClient bttv_ws_client; #endif @@ -113,8 +117,8 @@ int main(int argc, char *argv[]) { conn.close(); - bot::stream::StreamListenerClient stream_listener_client(helix_client, client, - cfg); + bot::stream::StreamListenerClient stream_listener_client( + helix_client, kick_api_client, client, cfg); bot::GithubListener github_listener(cfg, client, helix_client); @@ -196,10 +200,11 @@ int main(int argc, char *argv[]) { #endif client.on<bot::irc::MessageType::Privmsg>( - [&client, &command_loader, &localization, &cfg, &helix_client]( + [&client, &command_loader, &localization, &cfg, &helix_client, + &kick_api_client]( const bot::irc::Message<bot::irc::MessageType::Privmsg> &message) { - bot::InstanceBundle bundle{client, helix_client, localization, cfg, - command_loader}; + bot::InstanceBundle bundle{client, helix_client, kick_api_client, + localization, cfg, command_loader}; pqxx::connection conn(GET_DATABASE_CONNECTION_URL(cfg)); @@ -218,6 +223,8 @@ int main(int argc, char *argv[]) { threads.push_back(std::thread(&bot::GithubListener::run, &github_listener)); threads.push_back( std::thread(bot::emotes::create_emote_thread, &emote_bundle)); + threads.push_back(std::thread(&bot::api::KickAPIClient::refresh_token_thread, + &kick_api_client)); for (auto &thread : threads) { thread.join(); diff --git a/bot/src/schemas/stream.cpp b/bot/src/schemas/stream.cpp index f6df3f4..0d21caf 100644 --- a/bot/src/schemas/stream.cpp +++ b/bot/src/schemas/stream.cpp @@ -10,6 +10,14 @@ namespace bot::schemas { return EventType::TITLE; } else if (type == "game") { return EventType::GAME; + } else if (type == "kick_live") { + return EventType::KICK_LIVE; + } else if (type == "kick_offline") { + return EventType::KICK_OFFLINE; + } else if (type == "kick_title") { + return EventType::KICK_TITLE; + } else if (type == "kick_game") { + return EventType::KICK_GAME; } else if (type == "github") { return EventType::GITHUB; } else if (type == "7tv_emote_add") { @@ -42,6 +50,14 @@ namespace bot::schemas { return "title"; } else if (type == GAME) { return "game"; + } else if (type == KICK_LIVE) { + return "kick_live"; + } else if (type == KICK_OFFLINE) { + return "kick_offline"; + } else if (type == KICK_TITLE) { + return "kick_title"; + } else if (type == KICK_GAME) { + return "kick_game"; } else if (type == GITHUB) { return "github"; } else if (type == STV_EMOTE_CREATE) { diff --git a/bot/src/schemas/stream.hpp b/bot/src/schemas/stream.hpp index 348eccb..7e089c7 100644 --- a/bot/src/schemas/stream.hpp +++ b/bot/src/schemas/stream.hpp @@ -9,6 +9,10 @@ namespace bot::schemas { OFFLINE, TITLE, GAME, + KICK_LIVE, + KICK_OFFLINE, + KICK_TITLE, + KICK_GAME, STV_EMOTE_CREATE = 10, STV_EMOTE_DELETE, STV_EMOTE_UPDATE, diff --git a/bot/src/stream.cpp b/bot/src/stream.cpp index e04011c..107b883 100644 --- a/bot/src/stream.cpp +++ b/bot/src/stream.cpp @@ -7,6 +7,7 @@ #include <thread> #include <vector> +#include "api/kick.hpp" #include "api/twitch/schemas/stream.hpp" #include "config.hpp" #include "logger.hpp" @@ -15,15 +16,19 @@ #include "utils/string.hpp" namespace bot::stream { - void StreamListenerClient::listen_channel(const int &id) { - this->streamers.push_back({id, TWITCH, false, "", ""}); + void StreamListenerClient::listen_channel(const int &id, + const StreamerType &type) { + this->streamers.push_back({id, type, false, "", ""}); log::info("TwitchStreamListener", "Listening stream events for ID " + std::to_string(id)); } - void StreamListenerClient::unlisten_channel(const int &id) { + void StreamListenerClient::unlisten_channel(const int &id, + const StreamerType &type) { auto x = std::find_if(this->streamers.begin(), this->streamers.end(), - [&id](const StreamerData &x) { return x.id == id; }); + [&id, &type](const StreamerData &x) { + return x.id == id && x.type == type; + }); if (x != this->streamers.end()) { this->streamers.erase(x); @@ -39,15 +44,18 @@ namespace bot::stream { } void StreamListenerClient::check() { - std::vector<int> twitch_ids; + std::vector<int> twitch_ids, kick_ids; std::for_each(this->streamers.begin(), this->streamers.end(), - [&twitch_ids](const StreamerData &data) { + [&twitch_ids, &kick_ids](const StreamerData &data) { if (data.type == TWITCH) { twitch_ids.push_back(data.id); + } else if (data.type == KICK) { + kick_ids.push_back(data.id); } }); + auto kick_streams = this->kick_api_client.get_channels(kick_ids); auto twitch_streams = this->helix_client.get_streams(twitch_ids); auto now = std::chrono::system_clock::now(); auto now_time_it = std::chrono::system_clock::to_time_t(now); @@ -79,19 +87,55 @@ namespace bot::stream { } } - // removing ended livestreams - for (StreamerData &data : this->streamers) { - if (data.type != TWITCH) { + for (const api::KickChannel &channel : kick_streams) { + auto data = std::find_if(this->streamers.begin(), this->streamers.end(), + [&channel](const StreamerData &data) { + return data.type == KICK && + data.id == channel.broadcaster_user_id; + }); + + if (data == this->streamers.end()) { continue; } - if (data.is_live && - !std::any_of( - twitch_streams.begin(), twitch_streams.end(), - [&data](const auto &s) { return s.get_user_id() == data.id; })) { + if (!data->is_live && channel.is_live) { + data->is_live = true; + + auto difference = now - channel.start_time; + auto difference_min = + std::chrono::duration_cast<std::chrono::minutes>(difference); + + if (difference_min.count() < 1) { + this->handler(schemas::EventType::KICK_LIVE, + {channel.broadcaster_user_id, channel.slug, + channel.stream_game_name, channel.stream_title}, + *data); + } + } + } + + // removing ended livestreams + for (StreamerData &data : this->streamers) { + bool in_twitch_streams = std::any_of( + twitch_streams.begin(), twitch_streams.end(), [&data](const auto &s) { + return s.get_user_id() == data.id && data.type == TWITCH; + }); + + bool in_kick_streams = + std::any_of(kick_streams.begin(), kick_streams.end(), + [&data](const api::KickChannel &s) { + return s.broadcaster_user_id == data.id && + data.type == KICK && s.is_live; + }); + + if (data.type == TWITCH && data.is_live && !in_twitch_streams) { data.is_live = false; this->handler(schemas::EventType::OFFLINE, api::twitch::schemas::Stream{data.id}, data); + } else if (data.type == KICK && data.is_live && !in_kick_streams) { + data.is_live = false; + this->handler(schemas::EventType::KICK_OFFLINE, + api::twitch::schemas::Stream{data.id}, data); } } @@ -126,6 +170,40 @@ namespace bot::stream { data->game = stream.get_game_name(); } } + + for (const api::KickChannel &channel : kick_streams) { + auto data = std::find_if(this->streamers.begin(), this->streamers.end(), + [&channel](const StreamerData &data) { + return data.type == KICK && + data.id == channel.broadcaster_user_id; + }); + + if (data == this->streamers.end()) { + continue; + } + + api::twitch::schemas::Stream stream{ + channel.broadcaster_user_id, channel.slug, channel.stream_game_name, + channel.stream_title}; + + if (channel.stream_title != data->title && + !channel.stream_title.empty()) { + if (!data->title.empty()) { + this->handler(schemas::EventType::KICK_TITLE, stream, *data); + } + + data->title = channel.stream_title; + } + + if (channel.stream_game_name != data->game && + !channel.stream_game_name.empty()) { + if (!data->game.empty()) { + this->handler(schemas::EventType::KICK_GAME, stream, *data); + } + + data->game = channel.stream_game_name; + } + } } void StreamListenerClient::handler(const schemas::EventType &type, @@ -179,17 +257,21 @@ namespace bot::stream { int pos = base.find("{old}"); if (pos != std::string::npos) { - if (type == schemas::EventType::TITLE) + if (type == schemas::EventType::TITLE || + type == schemas::EventType::KICK_TITLE) base.replace(pos, 5, data.title); - else if (type == schemas::EventType::GAME) + else if (type == schemas::EventType::GAME || + type == schemas::EventType::KICK_GAME) base.replace(pos, 5, data.game); } pos = base.find("{new}"); if (pos != std::string::npos) { - if (type == schemas::EventType::TITLE) + if (type == schemas::EventType::TITLE || + type == schemas::EventType::KICK_TITLE) base.replace(pos, 5, stream.get_title()); - else if (type == schemas::EventType::GAME) + else if (type == schemas::EventType::GAME || + type == schemas::EventType::KICK_GAME) base.replace(pos, 5, stream.get_game_name()); } @@ -210,19 +292,25 @@ namespace bot::stream { pqxx::work work(conn); pqxx::result ids = - work.exec("SELECT name FROM events WHERE event_type < 10"); + work.exec("SELECT name, event_type FROM events WHERE event_type < 10"); for (const auto &row : ids) { int id = row[0].as<int>(); + int event_type = row[1].as<int>(); + + StreamerType type = (event_type >= schemas::EventType::KICK_LIVE && + event_type <= schemas::EventType::KICK_GAME) + ? StreamerType::KICK + : StreamerType::TWITCH; if (std::any_of(this->streamers.begin(), this->streamers.end(), - [&id](const StreamerData &x) { - return x.type == TWITCH && x.id == id; + [&id, &type](const StreamerData &x) { + return x.type == type && x.id == id; })) { continue; } - listen_channel(id); + listen_channel(id, type); } work.commit(); diff --git a/bot/src/stream.hpp b/bot/src/stream.hpp index 3ac71e3..462c2a6 100644 --- a/bot/src/stream.hpp +++ b/bot/src/stream.hpp @@ -3,6 +3,7 @@ #include <set> #include <vector> +#include "api/kick.hpp" #include "api/twitch/helix_client.hpp" #include "api/twitch/schemas/stream.hpp" #include "config.hpp" @@ -10,7 +11,7 @@ #include "schemas/stream.hpp" namespace bot::stream { - enum StreamerType { TWITCH }; + enum StreamerType { TWITCH, KICK }; struct StreamerData { int id; @@ -23,16 +24,18 @@ namespace bot::stream { class StreamListenerClient { public: StreamListenerClient(const api::twitch::HelixClient &helix_client, + const api::KickAPIClient &kick_api_client, irc::Client &irc_client, const Configuration &configuration) : helix_client(helix_client), + kick_api_client(kick_api_client), irc_client(irc_client), configuration(configuration) {}; ~StreamListenerClient() = default; void run(); - void listen_channel(const int &id); - void unlisten_channel(const int &id); + void listen_channel(const int &id, const StreamerType &type); + void unlisten_channel(const int &id, const StreamerType &type); private: void check(); @@ -42,6 +45,7 @@ namespace bot::stream { void update_channel_ids(); const api::twitch::HelixClient &helix_client; + const api::KickAPIClient &kick_api_client; irc::Client &irc_client; const Configuration &configuration; |
