summaryrefslogtreecommitdiff
path: root/bot
diff options
context:
space:
mode:
authorilotterytea <iltsu@alright.party>2025-07-01 18:54:10 +0500
committerilotterytea <iltsu@alright.party>2025-07-01 18:54:10 +0500
commit8c2e0d8c1faabb76f2e75cddc8b7da2caabf8da6 (patch)
tree5d227bd7529c0fa7e248737d430212275621bb93 /bot
parent25a25a07a7b68443791974800dbfc77d223392d6 (diff)
feat: KICK SUPPORT RAAAAH!!!! emojiAngry
Diffstat (limited to 'bot')
-rw-r--r--bot/src/api/kick.cpp125
-rw-r--r--bot/src/api/kick.hpp39
-rw-r--r--bot/src/bundle.hpp2
-rw-r--r--bot/src/commands/lua.cpp25
-rw-r--r--bot/src/commands/lua.hpp2
-rw-r--r--bot/src/config.cpp8
-rw-r--r--bot/src/config.hpp5
-rw-r--r--bot/src/main.cpp17
-rw-r--r--bot/src/schemas/stream.cpp16
-rw-r--r--bot/src/schemas/stream.hpp4
-rw-r--r--bot/src/stream.cpp130
-rw-r--r--bot/src/stream.hpp10
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;