diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce9910951..e88f25334 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,9 @@ name: Test on: pull_request: +env: + TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.3 + jobs: test: runs-on: ${{ matrix.os }} @@ -77,6 +80,8 @@ jobs: - name: Test (Ubuntu) if: startsWith(matrix.os, 'ubuntu') run: | + docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} + docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} ./bin/chatterino-test --platform minimal working-directory: build-test shell: bash diff --git a/.gitmodules b/.gitmodules index adef9ede4..3694e06f0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -29,3 +29,6 @@ [submodule "cmake/sanitizers-cmake"] path = cmake/sanitizers-cmake url = https://github.com/arsenm/sanitizers-cmake +[submodule "lib/magic_enum"] + path = lib/magic_enum + url = https://github.com/Neargye/magic_enum diff --git a/CHANGELOG.md b/CHANGELOG.md index c87ad5517..540e2ff3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: Adjust large stream thumbnail to 16:9 (#3655) - Minor: Fixed being unable to load Twitch Usercards from the `/mentions` tab. (#3623) - Minor: Add information about the user's operating system in the About page. (#3663) +- Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643) - Minor: Added chatter count for each category in viewer list. (#3683) - Minor: Sorted usernames in /vips message to be case-insensitive. (#3696) - Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) diff --git a/CMakeLists.txt b/CMakeLists.txt index f29563160..b2cd73c37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ endif () find_package(PajladaSerialize REQUIRED) find_package(PajladaSignals REQUIRED) find_package(LRUCache REQUIRED) +find_package(MagicEnum REQUIRED) if (USE_SYSTEM_PAJLADA_SETTINGS) find_package(PajladaSettings REQUIRED) diff --git a/chatterino.pro b/chatterino.pro index 97089e66b..5cccdc935 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -96,6 +96,7 @@ include(lib/signals.pri) include(lib/settings.pri) include(lib/serialize.pri) include(lib/lrucache.pri) +include(lib/magic_enum.pri) include(lib/winsdk.pri) include(lib/rapidjson.pri) include(lib/qtkeychain.pri) @@ -212,9 +213,16 @@ SOURCES += \ src/providers/twitch/api/Helix.cpp \ src/providers/twitch/ChannelPointReward.cpp \ src/providers/twitch/IrcMessageHandler.cpp \ - src/providers/twitch/PubsubActions.cpp \ - src/providers/twitch/PubsubClient.cpp \ - src/providers/twitch/PubsubHelpers.cpp \ + src/providers/twitch/PubSubActions.cpp \ + src/providers/twitch/PubSubClient.cpp \ + src/providers/twitch/PubSubManager.cpp \ + src/providers/twitch/pubsubmessages/AutoMod.cpp \ + src/providers/twitch/pubsubmessages/Base.cpp \ + src/providers/twitch/pubsubmessages/ChannelPoints.cpp \ + src/providers/twitch/pubsubmessages/ChatModeratorAction.cpp \ + src/providers/twitch/pubsubmessages/Listen.cpp \ + src/providers/twitch/pubsubmessages/Unlisten.cpp \ + src/providers/twitch/pubsubmessages/Whisper.cpp \ src/providers/twitch/TwitchAccount.cpp \ src/providers/twitch/TwitchAccountManager.cpp \ src/providers/twitch/TwitchBadge.cpp \ @@ -432,6 +440,7 @@ HEADERS += \ src/messages/search/LinkPredicate.hpp \ src/messages/search/MessageFlagsPredicate.hpp \ src/messages/search/MessagePredicate.hpp \ + src/messages/search/RegexPredicate.hpp \ src/messages/search/SubstringPredicate.hpp \ src/messages/Selection.hpp \ src/messages/SharedMessageBuilder.hpp \ @@ -458,9 +467,21 @@ HEADERS += \ src/providers/twitch/ChatterinoWebSocketppLogger.hpp \ src/providers/twitch/EmoteValue.hpp \ src/providers/twitch/IrcMessageHandler.hpp \ - src/providers/twitch/PubsubActions.hpp \ - src/providers/twitch/PubsubClient.hpp \ - src/providers/twitch/PubsubHelpers.hpp \ + src/providers/twitch/PubSubActions.hpp \ + src/providers/twitch/PubSubClient.hpp \ + src/providers/twitch/PubSubClientOptions.hpp \ + src/providers/twitch/PubSubHelpers.hpp \ + src/providers/twitch/PubSubManager.hpp \ + src/providers/twitch/PubSubMessages.hpp \ + src/providers/twitch/pubsubmessages/AutoMod.hpp \ + src/providers/twitch/pubsubmessages/Base.hpp \ + src/providers/twitch/pubsubmessages/ChannelPoints.hpp \ + src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp \ + src/providers/twitch/pubsubmessages/Listen.hpp \ + src/providers/twitch/pubsubmessages/Message.hpp \ + src/providers/twitch/pubsubmessages/Unlisten.hpp \ + src/providers/twitch/pubsubmessages/Whisper.hpp \ + src/providers/twitch/PubSubWebsocket.hpp \ src/providers/twitch/TwitchAccount.hpp \ src/providers/twitch/TwitchAccountManager.hpp \ src/providers/twitch/TwitchBadge.hpp \ diff --git a/cmake/FindMagicEnum.cmake b/cmake/FindMagicEnum.cmake new file mode 100644 index 000000000..0a77bd279 --- /dev/null +++ b/cmake/FindMagicEnum.cmake @@ -0,0 +1,14 @@ +include(FindPackageHandleStandardArgs) + +find_path(MagicEnum_INCLUDE_DIR magic_enum.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/magic_enum/include) + +find_package_handle_standard_args(MagicEnum DEFAULT_MSG MagicEnum_INCLUDE_DIR) + +if (MagicEnum_FOUND) + add_library(MagicEnum INTERFACE IMPORTED) + set_target_properties(MagicEnum PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${MagicEnum_INCLUDE_DIR}" + ) +endif () + +mark_as_advanced(MagicEnum_INCLUDE_DIR) diff --git a/lib/magic_enum b/lib/magic_enum new file mode 160000 index 000000000..b2ac76235 --- /dev/null +++ b/lib/magic_enum @@ -0,0 +1 @@ +Subproject commit b2ac76235b2261305bdfe562eb5982c808d07e73 diff --git a/lib/magic_enum.pri b/lib/magic_enum.pri new file mode 100644 index 000000000..15f1f21c2 --- /dev/null +++ b/lib/magic_enum.pri @@ -0,0 +1 @@ +INCLUDEPATH += $$PWD/magic_enum/include/ diff --git a/resources/licenses/magic_enum.txt b/resources/licenses/magic_enum.txt new file mode 100644 index 000000000..05b298b75 --- /dev/null +++ b/resources/licenses/magic_enum.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2022 Daniil Goncharov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index e83762905..550bf3437 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -59,6 +59,7 @@ licenses/emoji-data-source.txt licenses/libcommuni_BSD3.txt licenses/lrucache.txt + licenses/magic_enum.txt licenses/openssl.txt licenses/pajlada_settings.txt licenses/pajlada_signals.txt diff --git a/src/Application.cpp b/src/Application.cpp index 357e39932..3be36ae3a 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -17,7 +17,7 @@ #include "providers/ffz/FfzBadges.hpp" #include "providers/ffz/FfzEmotes.hpp" #include "providers/irc/Irc2.hpp" -#include "providers/twitch/PubsubClient.hpp" +#include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" @@ -31,6 +31,7 @@ #include "singletons/Toasts.hpp" #include "singletons/Updates.hpp" #include "singletons/WindowManager.hpp" +#include "util/Helpers.hpp" #include "util/IsBigEndian.hpp" #include "util/PostToThread.hpp" #include "util/RapidjsonHelpers.hpp" @@ -137,7 +138,7 @@ void Application::initialize(Settings &settings, Paths &paths) { this->initNm(paths); } - this->initPubsub(); + this->initPubSub(); } int Application::run(QApplication &qtApp) @@ -194,7 +195,7 @@ void Application::initNm(Paths &paths) #endif } -void Application::initPubsub() +void Application::initPubSub() { this->twitch->pubsub->signals_.moderation.chatCleared.connect( [this](const auto &action) { @@ -331,21 +332,105 @@ void Application::initPubsub() }); }); - this->twitch->pubsub->signals_.moderation.automodMessage.connect( - [&](const auto &action) { - auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); + const auto handleAutoModMessage = [&](const auto &action) { + auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); - if (chan->isEmpty()) - { - return; - } + if (chan->isEmpty()) + { + return; + } - postToThread([chan, action] { - const auto p = makeAutomodMessage(action); - chan->addMessage(p.first); - chan->addMessage(p.second); - }); + postToThread([chan, action] { + const auto p = makeAutomodMessage(action); + chan->addMessage(p.first); + chan->addMessage(p.second); }); + }; + + this->twitch->pubsub->signals_.moderation.autoModMessageCaught.connect( + [&](const auto &msg, const QString &channelID) { + switch (msg.type) + { + case PubSubAutoModQueueMessage::Type::AutoModCaughtMessage: { + if (msg.status == "PENDING") + { + AutomodAction action(msg.data, channelID); + action.reason = QString("%1 level %2") + .arg(msg.contentCategory) + .arg(msg.contentLevel); + + action.msgID = msg.messageID; + action.message = msg.messageText; + + // this message also contains per-word automod data, which could be implemented + + // extract sender data manually because Twitch loves not being consistent + QString senderDisplayName = + msg.senderUserDisplayName; // Might be transformed later + bool hasLocalizedName = false; + if (!msg.senderUserDisplayName.isEmpty()) + { + // check for non-ascii display names + if (QString::compare(msg.senderUserDisplayName, + msg.senderUserLogin, + Qt::CaseInsensitive) != 0) + { + hasLocalizedName = true; + } + } + QColor senderColor = msg.senderUserChatColor; + QString senderColor_; + if (!senderColor.isValid() && + getSettings()->colorizeNicknames) + { + // color may be not present if user is a grey-name + senderColor = getRandomColor(msg.senderUserID); + } + + // handle username style based on prefered setting + switch (getSettings()->usernameDisplayMode.getValue()) + { + case UsernameDisplayMode::Username: { + if (hasLocalizedName) + { + senderDisplayName = msg.senderUserLogin; + } + break; + } + case UsernameDisplayMode::LocalizedName: { + break; + } + case UsernameDisplayMode:: + UsernameAndLocalizedName: { + if (hasLocalizedName) + { + senderDisplayName = QString("%1(%2)").arg( + msg.senderUserLogin, + msg.senderUserDisplayName); + } + break; + } + } + + action.target = + ActionUser{msg.senderUserID, msg.senderUserLogin, + senderDisplayName, senderColor}; + handleAutoModMessage(action); + } + // "ALLOWED" and "DENIED" statuses remain unimplemented + // They are versions of automod_message_(denied|approved) but for mods. + } + break; + + case PubSubAutoModQueueMessage::Type::INVALID: + default: { + } + break; + } + }); + + this->twitch->pubsub->signals_.moderation.autoModMessageBlocked.connect( + handleAutoModMessage); this->twitch->pubsub->signals_.moderation.automodUserMessage.connect( [&](const auto &action) { @@ -381,39 +466,44 @@ void Application::initPubsub() this->twitch->pubsub->signals_.pointReward.redeemed.connect( [&](auto &data) { - QString channelId; - if (rj::getSafe(data, "channel_id", channelId)) - { - auto chan = this->twitch->getChannelOrEmptyByID(channelId); - - auto reward = ChannelPointReward(data); - - postToThread([chan, reward] { - if (auto channel = - dynamic_cast(chan.get())) - { - channel->addChannelPointReward(reward); - } - }); - } - else + QString channelId = data.value("channel_id").toString(); + if (channelId.isEmpty()) { qCDebug(chatterinoApp) << "Couldn't find channel id of point reward"; + return; } + + auto chan = this->twitch->getChannelOrEmptyByID(channelId); + + auto reward = ChannelPointReward(data); + + postToThread([chan, reward] { + if (auto channel = dynamic_cast(chan.get())) + { + channel->addChannelPointReward(reward); + } + }); }); this->twitch->pubsub->start(); auto RequestModerationActions = [=]() { - this->twitch->pubsub->unlistenAllModerationActions(); + this->twitch->pubsub->setAccount( + getApp()->accounts->twitch.getCurrent()); // TODO(pajlada): Unlisten to all authed topics instead of only // moderation topics this->twitch->pubsub->UnlistenAllAuthedTopics(); - this->twitch->pubsub->listenToWhispers( - this->accounts->twitch.getCurrent()); + this->twitch->pubsub->listenToWhispers(); }; + this->accounts->twitch.currentUserChanged.connect( + [=] { + this->twitch->pubsub->unlistenAllModerationActions(); + this->twitch->pubsub->unlistenWhispers(); + }, + boost::signals2::at_front); + this->accounts->twitch.currentUserChanged.connect(RequestModerationActions); RequestModerationActions(); diff --git a/src/Application.hpp b/src/Application.hpp index 5091322af..846b8231d 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -66,7 +66,7 @@ public: private: void addSingleton(Singleton *singleton); - void initPubsub(); + void initPubSub(); void initNm(Paths &paths); template hasParsedSuccessfully = - rj::getSafeObject(redemption, "user", user))) - { - qCDebug(chatterinoTwitch) << "No user info found for redemption"; - return; - } - - rapidjson::Value reward; - if (!(this->hasParsedSuccessfully = - rj::getSafeObject(redemption, "reward", reward))) - { - qCDebug(chatterinoTwitch) << "No reward info found for redemption"; - return; - } - - if (!(this->hasParsedSuccessfully = rj::getSafe(reward, "id", this->id))) - { - qCDebug(chatterinoTwitch) << "No id found for reward"; - return; - } - - if (!(this->hasParsedSuccessfully = - rj::getSafe(reward, "channel_id", this->channelId))) - { - qCDebug(chatterinoTwitch) << "No channel_id found for reward"; - return; - } - - if (!(this->hasParsedSuccessfully = - rj::getSafe(reward, "title", this->title))) - { - qCDebug(chatterinoTwitch) << "No title found for reward"; - return; - } - - if (!(this->hasParsedSuccessfully = - rj::getSafe(reward, "cost", this->cost))) - { - qCDebug(chatterinoTwitch) << "No cost found for reward"; - return; - } - - if (!(this->hasParsedSuccessfully = rj::getSafe( - reward, "is_user_input_required", this->isUserInputRequired))) - { - qCDebug(chatterinoTwitch) - << "No information if user input is required found for reward"; - return; - } + this->id = reward.value("id").toString(); + this->channelId = reward.value("channel_id").toString(); + this->title = reward.value("title").toString(); + this->cost = reward.value("cost").toInt(); + this->isUserInputRequired = reward.value("is_user_input_required").toBool(); // We don't need to store user information for rewards with user input // because we will get the user info from a corresponding IRC message if (!this->isUserInputRequired) { - this->parseUser(user); + auto user = redemption.value("user").toObject(); + + this->user.id = user.value("id").toString(); + this->user.login = user.value("login").toString(); + this->user.displayName = user.value("display_name").toString(); } - rapidjson::Value obj; - if (rj::getSafeObject(reward, "image", obj) && !obj.IsNull() && - obj.IsObject()) + auto imageValue = reward.value("image"); + + if (imageValue.isObject()) { + auto imageObject = imageValue.toObject(); this->image = ImageSet{ - Image::fromUrl( - {parseRewardImage(obj, "url_1x", this->hasParsedSuccessfully)}, - 1), - Image::fromUrl( - {parseRewardImage(obj, "url_2x", this->hasParsedSuccessfully)}, - 0.5), - Image::fromUrl( - {parseRewardImage(obj, "url_4x", this->hasParsedSuccessfully)}, - 0.25), + Image::fromUrl({imageObject.value("url_1x").toString()}, 1), + Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5), + Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25), }; } else @@ -104,27 +45,4 @@ ChannelPointReward::ChannelPointReward(rapidjson::Value &redemption) } } -void ChannelPointReward::parseUser(rapidjson::Value &user) -{ - if (!(this->hasParsedSuccessfully = rj::getSafe(user, "id", this->user.id))) - { - qCDebug(chatterinoTwitch) << "No id found for user in reward"; - return; - } - - if (!(this->hasParsedSuccessfully = - rj::getSafe(user, "login", this->user.login))) - { - qCDebug(chatterinoTwitch) << "No login name found for user in reward"; - return; - } - - if (!(this->hasParsedSuccessfully = - rj::getSafe(user, "display_name", this->user.displayName))) - { - qCDebug(chatterinoTwitch) << "No display name found for user in reward"; - return; - } -} - } // namespace chatterino diff --git a/src/providers/twitch/ChannelPointReward.hpp b/src/providers/twitch/ChannelPointReward.hpp index 65885bcda..fad2ed375 100644 --- a/src/providers/twitch/ChannelPointReward.hpp +++ b/src/providers/twitch/ChannelPointReward.hpp @@ -4,7 +4,7 @@ #include "messages/Image.hpp" #include "messages/ImageSet.hpp" -#include +#include #define TWITCH_CHANNEL_POINT_REWARD_URL(x) \ QString("https://static-cdn.jtvnw.net/custom-reward-images/default-%1") \ @@ -12,14 +12,13 @@ namespace chatterino { struct ChannelPointReward { - ChannelPointReward(rapidjson::Value &reward); + ChannelPointReward(const QJsonObject &redemption); ChannelPointReward() = delete; QString id; QString channelId; QString title; int cost; ImageSet image; - bool hasParsedSuccessfully = false; bool isUserInputRequired = false; struct { @@ -27,9 +26,6 @@ struct ChannelPointReward { QString login; QString displayName; } user; - -private: - void parseUser(rapidjson::Value &user); }; } // namespace chatterino diff --git a/src/providers/twitch/PubSubActions.cpp b/src/providers/twitch/PubSubActions.cpp new file mode 100644 index 000000000..eb1e6ffbf --- /dev/null +++ b/src/providers/twitch/PubSubActions.cpp @@ -0,0 +1,13 @@ +#include "providers/twitch/PubSubActions.hpp" + +namespace chatterino { + +PubSubAction::PubSubAction(const QJsonObject &data, const QString &_roomID) + : timestamp(std::chrono::steady_clock::now()) + , roomID(_roomID) +{ + this->source.id = data.value("created_by_user_id").toString(); + this->source.login = data.value("created_by").toString(); +} + +} // namespace chatterino diff --git a/src/providers/twitch/PubsubActions.hpp b/src/providers/twitch/PubSubActions.hpp similarity index 83% rename from src/providers/twitch/PubsubActions.hpp rename to src/providers/twitch/PubSubActions.hpp index abe106df1..d6361738b 100644 --- a/src/providers/twitch/PubsubActions.hpp +++ b/src/providers/twitch/PubSubActions.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include @@ -15,10 +15,24 @@ struct ActionUser { // displayName should be in format "login(localizedName)" for non-ascii usernames QString displayName; QColor color; + + inline bool operator==(const ActionUser &rhs) const + { + return this->id == rhs.id && this->login == rhs.login && + this->displayName == rhs.displayName && this->color == rhs.color; + } }; +inline QDebug operator<<(QDebug dbg, const ActionUser &user) +{ + dbg.nospace() << "ActionUser(" << user.id << ", " << user.login << ", " + << user.displayName << ", " << user.color << ")"; + + return dbg.maybeSpace(); +} + struct PubSubAction { - PubSubAction(const rapidjson::Value &data, const QString &_roomID); + PubSubAction(const QJsonObject &data, const QString &_roomID); ActionUser source; std::chrono::steady_clock::time_point timestamp; diff --git a/src/providers/twitch/PubSubClient.cpp b/src/providers/twitch/PubSubClient.cpp new file mode 100644 index 000000000..c35d9a418 --- /dev/null +++ b/src/providers/twitch/PubSubClient.cpp @@ -0,0 +1,212 @@ +#include "providers/twitch/PubSubClient.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/PubSubActions.hpp" +#include "providers/twitch/PubSubHelpers.hpp" +#include "providers/twitch/PubSubMessages.hpp" +#include "providers/twitch/pubsubmessages/Unlisten.hpp" +#include "singletons/Settings.hpp" +#include "util/DebugCount.hpp" +#include "util/Helpers.hpp" +#include "util/RapidjsonHelpers.hpp" + +#include +#include + +namespace chatterino { + +static const char *PING_PAYLOAD = R"({"type":"PING"})"; + +PubSubClient::PubSubClient(WebsocketClient &websocketClient, + WebsocketHandle handle, + const PubSubClientOptions &clientOptions) + : websocketClient_(websocketClient) + , handle_(handle) + , clientOptions_(clientOptions) +{ +} + +void PubSubClient::start() +{ + assert(!this->started_); + + this->started_ = true; + + this->ping(); +} + +void PubSubClient::stop() +{ + assert(this->started_); + + this->started_ = false; +} + +void PubSubClient::close(const std::string &reason, + websocketpp::close::status::value code) +{ + WebsocketErrorCode ec; + + auto conn = this->websocketClient_.get_con_from_hdl(this->handle_, ec); + if (ec) + { + qCDebug(chatterinoPubSub) + << "Error getting con:" << ec.message().c_str(); + return; + } + + conn->close(code, reason, ec); + if (ec) + { + qCDebug(chatterinoPubSub) << "Error closing:" << ec.message().c_str(); + return; + } +} + +bool PubSubClient::listen(PubSubListenMessage msg) +{ + int numRequestedListens = msg.topics.size(); + + if (this->numListens_ + numRequestedListens > PubSubClient::MAX_LISTENS) + { + // This PubSubClient is already at its peak listens + return false; + } + this->numListens_ += numRequestedListens; + DebugCount::increase("PubSub topic pending listens", numRequestedListens); + + for (const auto &topic : msg.topics) + { + this->listeners_.emplace_back(Listener{topic, false, false, false}); + } + + qCDebug(chatterinoPubSub) + << "Subscribing to" << numRequestedListens << "topics"; + + this->send(msg.toJson()); + + return true; +} + +PubSubClient::UnlistenPrefixResponse PubSubClient::unlistenPrefix( + const QString &prefix) +{ + std::vector topics; + + for (auto it = this->listeners_.begin(); it != this->listeners_.end();) + { + const auto &listener = *it; + if (listener.topic.startsWith(prefix)) + { + topics.push_back(listener.topic); + it = this->listeners_.erase(it); + } + else + { + ++it; + } + } + + if (topics.empty()) + { + return {{}, ""}; + } + + auto numRequestedUnlistens = topics.size(); + + this->numListens_ -= numRequestedUnlistens; + DebugCount::increase("PubSub topic pending unlistens", + numRequestedUnlistens); + + PubSubUnlistenMessage message(topics); + + this->send(message.toJson()); + + return {message.topics, message.nonce}; +} + +void PubSubClient::handleListenResponse(const PubSubMessage &message) +{ +} + +void PubSubClient::handleUnlistenResponse(const PubSubMessage &message) +{ +} + +void PubSubClient::handlePong() +{ + assert(this->awaitingPong_); + + this->awaitingPong_ = false; +} + +bool PubSubClient::isListeningToTopic(const QString &topic) +{ + for (const auto &listener : this->listeners_) + { + if (listener.topic == topic) + { + return true; + } + } + + return false; +} + +std::vector PubSubClient::getListeners() const +{ + return this->listeners_; +} + +void PubSubClient::ping() +{ + assert(this->started_); + + if (this->awaitingPong_) + { + qCDebug(chatterinoPubSub) << "No pong response, disconnect!"; + this->close("Didn't respond to ping"); + + return; + } + + if (!this->send(PING_PAYLOAD)) + { + return; + } + + this->awaitingPong_ = true; + + auto self = this->shared_from_this(); + + runAfter(this->websocketClient_.get_io_service(), + this->clientOptions_.pingInterval_, [self](auto timer) { + if (!self->started_) + { + return; + } + + self->ping(); + }); +} + +bool PubSubClient::send(const char *payload) +{ + WebsocketErrorCode ec; + this->websocketClient_.send(this->handle_, payload, + websocketpp::frame::opcode::text, ec); + + if (ec) + { + qCDebug(chatterinoPubSub) << "Error sending message" << payload << ":" + << ec.message().c_str(); + // TODO(pajlada): Check which error code happened and maybe + // gracefully handle it + + return false; + } + + return true; +} + +} // namespace chatterino diff --git a/src/providers/twitch/PubSubClient.hpp b/src/providers/twitch/PubSubClient.hpp new file mode 100644 index 000000000..848328617 --- /dev/null +++ b/src/providers/twitch/PubSubClient.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "providers/twitch/PubSubClientOptions.hpp" +#include "providers/twitch/PubSubMessages.hpp" +#include "providers/twitch/PubSubWebsocket.hpp" + +#include +#include + +#include +#include + +namespace chatterino { + +struct TopicData { + QString topic; + bool authed{false}; + bool persistent{false}; +}; + +struct Listener : TopicData { + bool confirmed{false}; +}; + +class PubSubClient : public std::enable_shared_from_this +{ +public: + struct UnlistenPrefixResponse { + std::vector topics; + QString nonce; + }; + + // The max amount of topics we may listen to with a single connection + static constexpr std::vector::size_type MAX_LISTENS = 50; + + PubSubClient(WebsocketClient &_websocketClient, WebsocketHandle _handle, + const PubSubClientOptions &clientOptions); + + void start(); + void stop(); + + void close(const std::string &reason, + websocketpp::close::status::value code = + websocketpp::close::status::normal); + + bool listen(PubSubListenMessage msg); + UnlistenPrefixResponse unlistenPrefix(const QString &prefix); + + void handleListenResponse(const PubSubMessage &message); + void handleUnlistenResponse(const PubSubMessage &message); + + void handlePong(); + + bool isListeningToTopic(const QString &topic); + + std::vector getListeners() const; + +private: + void ping(); + bool send(const char *payload); + + WebsocketClient &websocketClient_; + WebsocketHandle handle_; + uint16_t numListens_ = 0; + + std::vector listeners_; + + std::atomic awaitingPong_{false}; + std::atomic started_{false}; + + const PubSubClientOptions &clientOptions_; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/PubSubClientOptions.hpp b/src/providers/twitch/PubSubClientOptions.hpp new file mode 100644 index 000000000..2bd576efd --- /dev/null +++ b/src/providers/twitch/PubSubClientOptions.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace chatterino { + +/** + * @brief Options to change the behaviour of the underlying websocket clients + **/ +struct PubSubClientOptions { + std::chrono::seconds pingInterval_; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/PubsubHelpers.hpp b/src/providers/twitch/PubSubHelpers.hpp similarity index 64% rename from src/providers/twitch/PubsubHelpers.hpp rename to src/providers/twitch/PubSubHelpers.hpp index 3896f07ff..d1e616c6a 100644 --- a/src/providers/twitch/PubsubHelpers.hpp +++ b/src/providers/twitch/PubSubHelpers.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,19 +12,6 @@ namespace chatterino { class TwitchAccount; struct ActionUser; -const rapidjson::Value &getArgs(const rapidjson::Value &data); -const rapidjson::Value &getMsgID(const rapidjson::Value &data); - -bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user); - -bool getTargetUser(const rapidjson::Value &data, ActionUser &user); -bool getTargetUserName(const rapidjson::Value &data, ActionUser &user); - -rapidjson::Document createListenMessage(const std::vector &topicsVec, - std::shared_ptr account); -rapidjson::Document createUnlistenMessage( - const std::vector &topicsVec); - // Create timer using given ioService template void runAfter(boost::asio::io_service &ioService, Duration duration, @@ -35,7 +23,7 @@ void runAfter(boost::asio::io_service &ioService, Duration duration, timer->async_wait([timer, cb](const boost::system::error_code &ec) { if (ec) { - qCDebug(chatterinoPubsub) + qCDebug(chatterinoPubSub) << "Error in runAfter:" << ec.message().c_str(); return; } @@ -54,7 +42,7 @@ void runAfter(std::shared_ptr timer, timer->async_wait([timer, cb](const boost::system::error_code &ec) { if (ec) { - qCDebug(chatterinoPubsub) + qCDebug(chatterinoPubSub) << "Error in runAfter:" << ec.message().c_str(); return; } diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp new file mode 100644 index 000000000..ada771860 --- /dev/null +++ b/src/providers/twitch/PubSubManager.cpp @@ -0,0 +1,1153 @@ +#include "providers/twitch/PubSubManager.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/PubSubActions.hpp" +#include "providers/twitch/PubSubHelpers.hpp" +#include "providers/twitch/PubSubMessages.hpp" +#include "util/DebugCount.hpp" +#include "util/Helpers.hpp" +#include "util/RapidjsonHelpers.hpp" + +#include +#include +#include +#include + +using websocketpp::lib::bind; +using websocketpp::lib::placeholders::_1; +using websocketpp::lib::placeholders::_2; + +namespace chatterino { + +PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) + : host_(host) + , clientOptions_({ + pingInterval, + }) +{ + this->moderationActionHandlers["clear"] = [this](const auto &data, + const auto &roomID) { + ClearChatAction action(data, roomID); + + this->signals_.moderation.chatCleared.invoke(action); + }; + + this->moderationActionHandlers["slowoff"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::Slow; + action.state = ModeChangedAction::State::Off; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["slow"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::Slow; + action.state = ModeChangedAction::State::On; + + const auto args = data.value("args").toArray(); + + if (args.empty()) + { + qCDebug(chatterinoPubSub) + << "Missing duration argument in slowmode on"; + return; + } + + bool ok; + + action.duration = args.at(0).toString().toUInt(&ok, 10); + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["r9kbetaoff"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::R9K; + action.state = ModeChangedAction::State::Off; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["r9kbeta"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::R9K; + action.state = ModeChangedAction::State::On; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["subscribersoff"] = + [this](const auto &data, const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::SubscribersOnly; + action.state = ModeChangedAction::State::Off; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["subscribers"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::SubscribersOnly; + action.state = ModeChangedAction::State::On; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["emoteonlyoff"] = + [this](const auto &data, const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::EmoteOnly; + action.state = ModeChangedAction::State::Off; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["emoteonly"] = [this](const auto &data, + const auto &roomID) { + ModeChangedAction action(data, roomID); + + action.mode = ModeChangedAction::Mode::EmoteOnly; + action.state = ModeChangedAction::State::On; + + this->signals_.moderation.modeChanged.invoke(action); + }; + + this->moderationActionHandlers["unmod"] = [this](const auto &data, + const auto &roomID) { + ModerationStateAction action(data, roomID); + + action.target.id = data.value("target_user_id").toString(); + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.target.login = args[0].toString(); + + action.modded = false; + + this->signals_.moderation.moderationStateChanged.invoke(action); + }; + + this->moderationActionHandlers["mod"] = [this](const auto &data, + const auto &roomID) { + ModerationStateAction action(data, roomID); + action.modded = true; + + auto innerType = data.value("type").toString(); + if (innerType == "chat_login_moderation") + { + // Don't display the old message type + return; + } + + action.target.id = data.value("target_user_id").toString(); + action.target.login = data.value("target_user_login").toString(); + + this->signals_.moderation.moderationStateChanged.invoke(action); + }; + + this->moderationActionHandlers["timeout"] = [this](const auto &data, + const auto &roomID) { + BanAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + const auto args = data.value("args").toArray(); + + if (args.size() < 2) + { + return; + } + + action.target.login = args[0].toString(); + bool ok; + action.duration = args[1].toString().toUInt(&ok, 10); + action.reason = args[2].toString(); // May be omitted + + this->signals_.moderation.userBanned.invoke(action); + }; + + this->moderationActionHandlers["delete"] = [this](const auto &data, + const auto &roomID) { + DeleteAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + const auto args = data.value("args").toArray(); + + if (args.size() < 3) + { + return; + } + + action.target.login = args[0].toString(); + bool ok; + action.messageText = args[1].toString(); + action.messageId = args[2].toString(); + + this->signals_.moderation.messageDeleted.invoke(action); + }; + + this->moderationActionHandlers["ban"] = [this](const auto &data, + const auto &roomID) { + BanAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.target.login = args[0].toString(); + action.reason = args[1].toString(); // May be omitted + + this->signals_.moderation.userBanned.invoke(action); + }; + + this->moderationActionHandlers["unban"] = [this](const auto &data, + const auto &roomID) { + UnbanAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + action.previousState = UnbanAction::Banned; + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.target.login = args[0].toString(); + + this->signals_.moderation.userUnbanned.invoke(action); + }; + + this->moderationActionHandlers["untimeout"] = [this](const auto &data, + const auto &roomID) { + UnbanAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + action.previousState = UnbanAction::TimedOut; + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.target.login = args[0].toString(); + + this->signals_.moderation.userUnbanned.invoke(action); + }; + + this->moderationActionHandlers["automod_rejected"] = + [this](const auto &data, const auto &roomID) { + AutomodAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.target.id = data.value("target_user_id").toString(); + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.msgID = data.value("msg_id").toString(); + + if (action.msgID.isEmpty()) + { + // Missing required msg_id parameter + return; + } + + action.target.login = args[0].toString(); + action.message = args[1].toString(); // May be omitted + action.reason = args[2].toString(); // May be omitted + + this->signals_.moderation.autoModMessageBlocked.invoke(action); + }; + + this->moderationActionHandlers["automod_message_rejected"] = + [this](const auto &data, const auto &roomID) { + AutomodInfoAction action(data, roomID); + action.type = AutomodInfoAction::OnHold; + this->signals_.moderation.automodInfoMessage.invoke(action); + }; + + this->moderationActionHandlers["automod_message_denied"] = + [this](const auto &data, const auto &roomID) { + AutomodInfoAction action(data, roomID); + action.type = AutomodInfoAction::Denied; + this->signals_.moderation.automodInfoMessage.invoke(action); + }; + + this->moderationActionHandlers["automod_message_approved"] = + [this](const auto &data, const auto &roomID) { + AutomodInfoAction action(data, roomID); + action.type = AutomodInfoAction::Approved; + this->signals_.moderation.automodInfoMessage.invoke(action); + }; + + this->channelTermsActionHandlers["add_permitted_term"] = + [this](const auto &data, const auto &roomID) { + // This term got a pass through automod + AutomodUserAction action(data, roomID); + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.type = AutomodUserAction::AddPermitted; + action.message = data.value("text").toString(); + action.source.login = data.value("requester_login").toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + this->channelTermsActionHandlers["add_blocked_term"] = + [this](const auto &data, const auto &roomID) { + // A term has been added + AutomodUserAction action(data, roomID); + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.type = AutomodUserAction::AddBlocked; + action.message = data.value("text").toString(); + action.source.login = data.value("requester_login").toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + this->moderationActionHandlers["delete_permitted_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + const auto args = data.value("args").toArray(); + action.type = AutomodUserAction::RemovePermitted; + + if (args.isEmpty()) + { + return; + } + + action.message = args[0].toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + this->channelTermsActionHandlers["delete_permitted_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.type = AutomodUserAction::RemovePermitted; + action.message = data.value("text").toString(); + action.source.login = data.value("requester_login").toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + this->moderationActionHandlers["delete_blocked_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + const auto args = data.value("args").toArray(); + action.type = AutomodUserAction::RemoveBlocked; + + if (args.isEmpty()) + { + return; + } + + action.message = args[0].toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + this->channelTermsActionHandlers["delete_blocked_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + action.type = AutomodUserAction::RemoveBlocked; + action.message = data.value("text").toString(); + action.source.login = data.value("requester_login").toString(); + + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + // We don't get this one anymore or anything similiar + // We need some new topic so we can listen + // + //this->moderationActionHandlers["modified_automod_properties"] = + // [this](const auto &data, const auto &roomID) { + // // The automod settings got modified + // AutomodUserAction action(data, roomID); + // getCreatedByUser(data, action.source); + // action.type = AutomodUserAction::Properties; + // this->signals_.moderation.automodUserMessage.invoke(action); + // }; + + this->moderationActionHandlers["denied_automod_message"] = + [](const auto &data, const auto &roomID) { + // This message got denied by a moderator + // qCDebug(chatterinoPubSub) << rj::stringify(data); + }; + + this->moderationActionHandlers["approved_automod_message"] = + [](const auto &data, const auto &roomID) { + // This message got approved by a moderator + // qCDebug(chatterinoPubSub) << rj::stringify(data); + }; + + this->websocketClient.set_access_channels(websocketpp::log::alevel::all); + this->websocketClient.clear_access_channels( + websocketpp::log::alevel::frame_payload | + websocketpp::log::alevel::frame_header); + + this->websocketClient.init_asio(); + + // SSL Handshake + this->websocketClient.set_tls_init_handler( + bind(&PubSub::onTLSInit, this, ::_1)); + + this->websocketClient.set_message_handler( + bind(&PubSub::onMessage, this, ::_1, ::_2)); + this->websocketClient.set_open_handler( + bind(&PubSub::onConnectionOpen, this, ::_1)); + this->websocketClient.set_close_handler( + bind(&PubSub::onConnectionClose, this, ::_1)); + this->websocketClient.set_fail_handler( + bind(&PubSub::onConnectionFail, this, ::_1)); +} + +void PubSub::addClient() +{ + if (this->addingClient) + { + return; + } + + qCDebug(chatterinoPubSub) << "Adding an additional client"; + + this->addingClient = true; + + websocketpp::lib::error_code ec; + auto con = + this->websocketClient.get_connection(this->host_.toStdString(), ec); + + if (ec) + { + qCDebug(chatterinoPubSub) + << "Unable to establish connection:" << ec.message().c_str(); + return; + } + + this->websocketClient.connect(con); +} + +void PubSub::start() +{ + this->work = std::make_shared( + this->websocketClient.get_io_service()); + this->mainThread.reset( + new std::thread(std::bind(&PubSub::runThread, this))); +} + +void PubSub::stop() +{ + this->stopping_ = true; + + for (const auto &client : this->clients) + { + client.second->close("Shutting down"); + } + + this->work.reset(); + + if (this->mainThread->joinable()) + { + this->mainThread->join(); + } + + assert(this->clients.empty()); +} + +void PubSub::unlistenAllModerationActions() +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (const auto &[topics, nonce] = + client->unlistenPrefix("chat_moderator_actions."); + !topics.empty()) + { + this->registerNonce(nonce, { + client, + "UNLISTEN", + topics, + topics.size(), + }); + } + } +} + +void PubSub::unlistenWhispers() +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (const auto &[topics, nonce] = client->unlistenPrefix("whispers."); + !topics.empty()) + { + this->registerNonce(nonce, { + client, + "UNLISTEN", + topics, + topics.size(), + }); + } + } +} + +bool PubSub::listenToWhispers() +{ + if (this->userID_.isEmpty()) + { + qCDebug(chatterinoPubSub) + << "Unable to listen to whispers topic, no user logged in"; + return false; + } + + static const QString topicFormat("whispers.%1"); + auto topic = topicFormat.arg(this->userID_); + + qCDebug(chatterinoPubSub) << "Listen to whispers" << topic; + + this->listenToTopic(topic); + + return true; +} + +void PubSub::listenToChannelModerationActions(const QString &channelID) +{ + if (this->userID_.isEmpty()) + { + qCDebug(chatterinoPubSub) << "Unable to listen to moderation actions " + "topic, no user logged in"; + return; + } + + static const QString topicFormat("chat_moderator_actions.%1.%2"); + assert(!channelID.isEmpty()); + + auto topic = topicFormat.arg(this->userID_, channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + + qCDebug(chatterinoPubSub) << "Listen to topic" << topic; + + this->listenToTopic(topic); +} + +void PubSub::listenToAutomod(const QString &channelID) +{ + if (this->userID_.isEmpty()) + { + qCDebug(chatterinoPubSub) + << "Unable to listen to automod topic, no user logged in"; + return; + } + + static const QString topicFormat("automod-queue.%1.%2"); + assert(!channelID.isEmpty()); + + auto topic = topicFormat.arg(this->userID_, channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + + qCDebug(chatterinoPubSub) << "Listen to topic" << topic; + + this->listenToTopic(topic); +} + +void PubSub::listenToChannelPointRewards(const QString &channelID) +{ + static const QString topicFormat("community-points-channel-v1.%1"); + assert(!channelID.isEmpty()); + + auto topic = topicFormat.arg(channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + qCDebug(chatterinoPubSub) << "Listen to topic" << topic; + + this->listenToTopic(topic); +} + +void PubSub::listen(PubSubListenMessage msg) +{ + if (this->tryListen(msg)) + { + return; + } + + this->addClient(); + + std::copy(msg.topics.begin(), msg.topics.end(), + std::back_inserter(this->requests)); + + DebugCount::increase("PubSub topic backlog", msg.topics.size()); +} + +bool PubSub::tryListen(PubSubListenMessage msg) +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (auto success = client->listen(msg); success) + { + this->registerNonce(msg.nonce, { + client, + "LISTEN", + msg.topics, + msg.topics.size(), + }); + return true; + } + } + + return false; +} + +void PubSub::registerNonce(QString nonce, NonceInfo info) +{ + this->nonces_[nonce] = std::move(info); +} + +boost::optional PubSub::findNonceInfo(QString nonce) +{ + // TODO: This should also DELETE the nonceinfo from the map + auto it = this->nonces_.find(nonce); + + if (it == this->nonces_.end()) + { + return boost::none; + } + + return it->second; +} + +bool PubSub::isListeningToTopic(const QString &topic) +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (client->isListeningToTopic(topic)) + { + return true; + } + } + + return false; +} + +void PubSub::onMessage(websocketpp::connection_hdl hdl, + WebsocketMessagePtr websocketMessage) +{ + this->diag.messagesReceived += 1; + + const auto &payload = + QString::fromStdString(websocketMessage->get_payload()); + + auto oMessage = parsePubSubBaseMessage(payload); + + if (!oMessage) + { + qCDebug(chatterinoPubSub) + << "Unable to parse incoming pubsub message" << payload; + this->diag.messagesFailedToParse += 1; + return; + } + + auto message = *oMessage; + + switch (message.type) + { + case PubSubMessage::Type::Pong: { + auto clientIt = this->clients.find(hdl); + + // If this assert goes off, there's something wrong with the connection + // creation/preserving code KKona + assert(clientIt != this->clients.end()); + + auto &client = *clientIt; + + client.second->handlePong(); + } + break; + + case PubSubMessage::Type::Response: { + this->handleResponse(message); + } + break; + + case PubSubMessage::Type::Message: { + auto oMessageMessage = message.toInner(); + if (!oMessageMessage) + { + qCDebug(chatterinoPubSub) << "Malformed MESSAGE:" << payload; + return; + } + + this->handleMessageResponse(*oMessageMessage); + } + break; + + case PubSubMessage::Type::INVALID: + default: { + qCDebug(chatterinoPubSub) + << "Unknown message type:" << message.typeString; + } + break; + } +} + +void PubSub::onConnectionOpen(WebsocketHandle hdl) +{ + this->diag.connectionsOpened += 1; + + DebugCount::increase("PubSub connections"); + this->addingClient = false; + + this->connectBackoff.reset(); + + auto client = std::make_shared(this->websocketClient, hdl, + this->clientOptions_); + + // We separate the starting from the constructor because we will want to use + // shared_from_this + client->start(); + + this->clients.emplace(hdl, client); + + qCDebug(chatterinoPubSub) << "PubSub connection opened!"; + + const auto topicsToTake = + (std::min)(this->requests.size(), PubSubClient::MAX_LISTENS); + + std::vector newTopics( + std::make_move_iterator(this->requests.begin()), + std::make_move_iterator(this->requests.begin() + topicsToTake)); + + this->requests.erase(this->requests.begin(), + this->requests.begin() + topicsToTake); + + PubSubListenMessage msg(newTopics); + msg.setToken(this->token_); + + if (auto success = client->listen(msg); !success) + { + qCWarning(chatterinoPubSub) << "Failed to listen to " << topicsToTake + << "new topics on new client"; + return; + } + DebugCount::decrease("PubSub topic backlog", msg.topics.size()); + + this->registerNonce(msg.nonce, { + client, + "LISTEN", + msg.topics, + topicsToTake, + }); + + if (!this->requests.empty()) + { + this->addClient(); + } +} + +void PubSub::onConnectionFail(WebsocketHandle hdl) +{ + this->diag.connectionsFailed += 1; + + DebugCount::increase("PubSub failed connections"); + if (auto conn = this->websocketClient.get_con_from_hdl(std::move(hdl))) + { + qCDebug(chatterinoPubSub) << "PubSub connection attempt failed (error: " + << conn->get_ec().message().c_str() << ")"; + } + else + { + qCDebug(chatterinoPubSub) + << "PubSub connection attempt failed but we can't " + "get the connection from a handle."; + } + + this->addingClient = false; + if (!this->requests.empty()) + { + runAfter(this->websocketClient.get_io_service(), + this->connectBackoff.next(), [this](auto timer) { + this->addClient(); // + }); + } +} + +void PubSub::onConnectionClose(WebsocketHandle hdl) +{ + qCDebug(chatterinoPubSub) << "Connection closed"; + this->diag.connectionsClosed += 1; + + DebugCount::decrease("PubSub connections"); + auto clientIt = this->clients.find(hdl); + + // If this assert goes off, there's something wrong with the connection + // creation/preserving code KKona + assert(clientIt != this->clients.end()); + + auto client = clientIt->second; + + this->clients.erase(clientIt); + + client->stop(); + + if (!this->stopping_) + { + auto clientListeners = client->getListeners(); + for (const auto &listener : clientListeners) + { + this->listenToTopic(listener.topic); + } + } +} + +PubSub::WebsocketContextPtr PubSub::onTLSInit(websocketpp::connection_hdl hdl) +{ + WebsocketContextPtr ctx( + new boost::asio::ssl::context(boost::asio::ssl::context::tlsv12)); + + try + { + ctx->set_options(boost::asio::ssl::context::default_workarounds | + boost::asio::ssl::context::no_sslv2 | + boost::asio::ssl::context::single_dh_use); + } + catch (const std::exception &e) + { + qCDebug(chatterinoPubSub) + << "Exception caught in OnTLSInit:" << e.what(); + } + + return ctx; +} + +void PubSub::handleResponse(const PubSubMessage &message) +{ + const bool failed = !message.error.isEmpty(); + + if (failed) + { + qCDebug(chatterinoPubSub) + << "Error" << message.error << "on nonce" << message.nonce; + } + + if (message.nonce.isEmpty()) + { + // Can't do any specific handling since no nonce was specified + return; + } + + if (auto oInfo = this->findNonceInfo(message.nonce); oInfo) + { + const auto info = *oInfo; + auto client = info.client.lock(); + if (!client) + { + qCDebug(chatterinoPubSub) << "Client associated with nonce" + << message.nonce << "is no longer alive"; + return; + } + if (info.messageType == "LISTEN") + { + client->handleListenResponse(message); + this->handleListenResponse(info, failed); + } + else if (info.messageType == "UNLISTEN") + { + client->handleUnlistenResponse(message); + this->handleUnlistenResponse(info, failed); + } + else + { + qCDebug(chatterinoPubSub) + << "Unhandled nonce message type" << info.messageType; + } + + return; + } + + qCDebug(chatterinoPubSub) << "Response on unused" << message.nonce + << "client/topic listener mismatch?"; +} + +void PubSub::handleListenResponse(const NonceInfo &info, bool failed) +{ + DebugCount::decrease("PubSub topic pending listens", info.topicCount); + if (failed) + { + this->diag.failedListenResponses++; + DebugCount::increase("PubSub topic failed listens", info.topicCount); + } + else + { + this->diag.listenResponses++; + DebugCount::increase("PubSub topic listening", info.topicCount); + } +} + +void PubSub::handleUnlistenResponse(const NonceInfo &info, bool failed) +{ + this->diag.unlistenResponses++; + DebugCount::decrease("PubSub topic pending unlistens", info.topicCount); + if (failed) + { + qCDebug(chatterinoPubSub) << "Failed unlistening to" << info.topics; + DebugCount::increase("PubSub topic failed unlistens", info.topicCount); + } + else + { + qCDebug(chatterinoPubSub) << "Successful unlistened to" << info.topics; + DebugCount::decrease("PubSub topic listening", info.topicCount); + } +} + +void PubSub::handleMessageResponse(const PubSubMessageMessage &message) +{ + QString topic = message.topic; + + if (topic.startsWith("whispers.")) + { + auto oInnerMessage = message.toInner(); + if (!oInnerMessage) + { + return; + } + auto whisperMessage = *oInnerMessage; + + switch (whisperMessage.type) + { + case PubSubWhisperMessage::Type::WhisperReceived: { + this->signals_.whisper.received.invoke(whisperMessage); + } + break; + case PubSubWhisperMessage::Type::WhisperSent: { + this->signals_.whisper.sent.invoke(whisperMessage); + } + break; + case PubSubWhisperMessage::Type::Thread: { + // Handle thread? + } + break; + + case PubSubWhisperMessage::Type::INVALID: + default: { + qCDebug(chatterinoPubSub) + << "Invalid whisper type:" << whisperMessage.typeString; + } + break; + } + } + else if (topic.startsWith("chat_moderator_actions.")) + { + auto oInnerMessage = + message.toInner(); + if (!oInnerMessage) + { + return; + } + + auto innerMessage = *oInnerMessage; + auto topicParts = topic.split("."); + assert(topicParts.length() == 3); + + // Channel ID where the moderator actions are coming from + auto channelID = topicParts[2]; + + switch (innerMessage.type) + { + case PubSubChatModeratorActionMessage::Type::ModerationAction: { + QString moderationAction = + innerMessage.data.value("moderation_action").toString(); + + auto handlerIt = + this->moderationActionHandlers.find(moderationAction); + + if (handlerIt == this->moderationActionHandlers.end()) + { + qCDebug(chatterinoPubSub) + << "No handler found for moderation action" + << moderationAction; + return; + } + // Invoke handler function + handlerIt->second(innerMessage.data, channelID); + } + break; + case PubSubChatModeratorActionMessage::Type::ChannelTermsAction: { + QString channelTermsAction = + innerMessage.data.value("type").toString(); + + auto handlerIt = + this->channelTermsActionHandlers.find(channelTermsAction); + + if (handlerIt == this->channelTermsActionHandlers.end()) + { + qCDebug(chatterinoPubSub) + << "No handler found for channel terms action" + << channelTermsAction; + return; + } + // Invoke handler function + handlerIt->second(innerMessage.data, channelID); + } + break; + + case PubSubChatModeratorActionMessage::Type::INVALID: + default: { + qCDebug(chatterinoPubSub) + << "Invalid whisper type:" << innerMessage.typeString; + } + break; + } + } + else if (topic.startsWith("community-points-channel-v1.")) + { + auto oInnerMessage = + message.toInner(); + if (!oInnerMessage) + { + return; + } + + auto innerMessage = *oInnerMessage; + + switch (innerMessage.type) + { + case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: { + auto redemption = + innerMessage.data.value("redemption").toObject(); + this->signals_.pointReward.redeemed.invoke(redemption); + } + break; + + case PubSubCommunityPointsChannelV1Message::Type::INVALID: + default: { + qCDebug(chatterinoPubSub) + << "Invalid point event type:" << innerMessage.typeString; + } + break; + } + } + else if (topic.startsWith("automod-queue.")) + { + auto oInnerMessage = message.toInner(); + if (!oInnerMessage) + { + return; + } + + auto innerMessage = *oInnerMessage; + + auto topicParts = topic.split("."); + assert(topicParts.length() == 3); + + // Channel ID where the moderator actions are coming from + auto channelID = topicParts[2]; + + this->signals_.moderation.autoModMessageCaught.invoke(innerMessage, + channelID); + } + else + { + qCDebug(chatterinoPubSub) << "Unknown topic:" << topic; + return; + } +} + +void PubSub::runThread() +{ + qCDebug(chatterinoPubSub) << "Start pubsub manager thread"; + this->websocketClient.run(); + qCDebug(chatterinoPubSub) << "Done with pubsub manager thread"; +} + +void PubSub::listenToTopic(const QString &topic) +{ + PubSubListenMessage msg({topic}); + msg.setToken(this->token_); + + this->listen(std::move(msg)); +} + +} // namespace chatterino diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp new file mode 100644 index 000000000..d078e519c --- /dev/null +++ b/src/providers/twitch/PubSubManager.hpp @@ -0,0 +1,198 @@ +#pragma once + +#include "providers/twitch/ChatterinoWebSocketppLogger.hpp" +#include "providers/twitch/PubSubActions.hpp" +#include "providers/twitch/PubSubClient.hpp" +#include "providers/twitch/PubSubClientOptions.hpp" +#include "providers/twitch/PubSubMessages.hpp" +#include "providers/twitch/PubSubWebsocket.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "util/ExponentialBackoff.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +class PubSub +{ + using WebsocketMessagePtr = + websocketpp::config::asio_tls_client::message_type::ptr; + using WebsocketContextPtr = + websocketpp::lib::shared_ptr; + + template + using Signal = + pajlada::Signals::Signal; // type-id is vector> + + struct NonceInfo { + std::weak_ptr client; + QString messageType; // e.g. LISTEN or UNLISTEN + std::vector topics; + std::vector::size_type topicCount; + }; + + WebsocketClient websocketClient; + std::unique_ptr mainThread; + + // Account credentials + // Set from setAccount or setAccountData + QString token_; + QString userID_; + +public: + // The max amount of connections we may open + static constexpr int maxConnections = 10; + + PubSub(const QString &host, + std::chrono::seconds pingInterval = std::chrono::seconds(15)); + + void setAccount(std::shared_ptr account) + { + this->token_ = account->getOAuthToken(); + this->userID_ = account->getUserId(); + } + + void setAccountData(QString token, QString userID) + { + this->token_ = token; + this->userID_ = userID; + } + + ~PubSub() = delete; + + enum class State { + Connected, + Disconnected, + }; + + void start(); + void stop(); + + bool isConnected() const + { + return this->state == State::Connected; + } + + struct { + struct { + Signal chatCleared; + Signal messageDeleted; + Signal modeChanged; + Signal moderationStateChanged; + + Signal userBanned; + Signal userUnbanned; + + // Message caught by automod + // channelID + pajlada::Signals::Signal + autoModMessageCaught; + + // Message blocked by moderator + Signal autoModMessageBlocked; + + Signal automodUserMessage; + Signal automodInfoMessage; + } moderation; + + struct { + // Parsing should be done in PubSubManager as well, + // but for now we just send the raw data + Signal received; + Signal sent; + } whisper; + + struct { + Signal redeemed; + } pointReward; + } signals_; + + void unlistenAllModerationActions(); + void unlistenWhispers(); + + bool listenToWhispers(); + void listenToChannelModerationActions(const QString &channelID); + void listenToAutomod(const QString &channelID); + + void listenToChannelPointRewards(const QString &channelID); + + std::vector requests; + + struct { + std::atomic connectionsClosed{0}; + std::atomic connectionsOpened{0}; + std::atomic connectionsFailed{0}; + std::atomic messagesReceived{0}; + std::atomic messagesFailedToParse{0}; + std::atomic failedListenResponses{0}; + std::atomic listenResponses{0}; + std::atomic unlistenResponses{0}; + } diag; + + void listenToTopic(const QString &topic); + +private: + void listen(PubSubListenMessage msg); + bool tryListen(PubSubListenMessage msg); + + bool isListeningToTopic(const QString &topic); + + void addClient(); + std::atomic addingClient{false}; + ExponentialBackoff<5> connectBackoff{std::chrono::milliseconds(1000)}; + + State state = State::Connected; + + std::map, + std::owner_less> + clients; + + std::unordered_map< + QString, std::function> + moderationActionHandlers; + + std::unordered_map< + QString, std::function> + channelTermsActionHandlers; + + void onMessage(websocketpp::connection_hdl hdl, WebsocketMessagePtr msg); + void onConnectionOpen(websocketpp::connection_hdl hdl); + void onConnectionFail(websocketpp::connection_hdl hdl); + void onConnectionClose(websocketpp::connection_hdl hdl); + WebsocketContextPtr onTLSInit(websocketpp::connection_hdl hdl); + + void handleResponse(const PubSubMessage &message); + void handleListenResponse(const NonceInfo &info, bool failed); + void handleUnlistenResponse(const NonceInfo &info, bool failed); + void handleMessageResponse(const PubSubMessageMessage &message); + + // Register a nonce for a specific client + void registerNonce(QString nonce, NonceInfo nonceInfo); + + // Find client associated with a nonce + boost::optional findNonceInfo(QString nonce); + + std::unordered_map nonces_; + + void runThread(); + + std::shared_ptr work{nullptr}; + + const QString host_; + const PubSubClientOptions clientOptions_; + + bool stopping_{false}; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/PubSubMessages.hpp b/src/providers/twitch/PubSubMessages.hpp new file mode 100644 index 000000000..f9cc5c501 --- /dev/null +++ b/src/providers/twitch/PubSubMessages.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "providers/twitch/pubsubmessages/AutoMod.hpp" +#include "providers/twitch/pubsubmessages/Base.hpp" +#include "providers/twitch/pubsubmessages/ChannelPoints.hpp" +#include "providers/twitch/pubsubmessages/ChatModeratorAction.hpp" +#include "providers/twitch/pubsubmessages/Listen.hpp" +#include "providers/twitch/pubsubmessages/Message.hpp" +#include "providers/twitch/pubsubmessages/Unlisten.hpp" +#include "providers/twitch/pubsubmessages/Whisper.hpp" diff --git a/src/providers/twitch/PubSubWebsocket.hpp b/src/providers/twitch/PubSubWebsocket.hpp new file mode 100644 index 000000000..068b3793d --- /dev/null +++ b/src/providers/twitch/PubSubWebsocket.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "providers/twitch/ChatterinoWebSocketppLogger.hpp" + +#include +#include +#include +#include + +namespace chatterino { + +struct chatterinoconfig : public websocketpp::config::asio_tls_client { + typedef websocketpp::log::chatterinowebsocketpplogger< + concurrency_type, websocketpp::log::elevel> + elog_type; + typedef websocketpp::log::chatterinowebsocketpplogger< + concurrency_type, websocketpp::log::alevel> + alog_type; + + struct permessage_deflate_config { + }; + + typedef websocketpp::extensions::permessage_deflate::disabled< + permessage_deflate_config> + permessage_deflate_type; +}; + +using WebsocketClient = websocketpp::client; +using WebsocketHandle = websocketpp::connection_hdl; +using WebsocketErrorCode = websocketpp::lib::error_code; + +} // namespace chatterino diff --git a/src/providers/twitch/PubsubActions.cpp b/src/providers/twitch/PubsubActions.cpp deleted file mode 100644 index 689a9a117..000000000 --- a/src/providers/twitch/PubsubActions.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include "providers/twitch/PubsubActions.hpp" - -#include "providers/twitch/PubsubHelpers.hpp" - -namespace chatterino { - -PubSubAction::PubSubAction(const rapidjson::Value &data, const QString &_roomID) - : timestamp(std::chrono::steady_clock::now()) - , roomID(_roomID) -{ - getCreatedByUser(data, this->source); -} - -} // namespace chatterino diff --git a/src/providers/twitch/PubsubClient.cpp b/src/providers/twitch/PubsubClient.cpp deleted file mode 100644 index 153603f6c..000000000 --- a/src/providers/twitch/PubsubClient.cpp +++ /dev/null @@ -1,1577 +0,0 @@ -#include "providers/twitch/PubsubClient.hpp" - -#include "providers/twitch/PubsubActions.hpp" -#include "providers/twitch/PubsubHelpers.hpp" -#include "singletons/Settings.hpp" -#include "util/DebugCount.hpp" -#include "util/Helpers.hpp" -#include "util/RapidjsonHelpers.hpp" - -#include - -#include -#include -#include -#include "common/QLogging.hpp" - -#define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv" - -using websocketpp::lib::bind; -using websocketpp::lib::placeholders::_1; -using websocketpp::lib::placeholders::_2; - -namespace chatterino { - -static const char *pingPayload = "{\"type\":\"PING\"}"; - -static std::map sentListens; -static std::map sentUnlistens; - -namespace detail { - - PubSubClient::PubSubClient(WebsocketClient &websocketClient, - WebsocketHandle handle) - : websocketClient_(websocketClient) - , handle_(handle) - { - } - - void PubSubClient::start() - { - assert(!this->started_); - - this->started_ = true; - - this->ping(); - } - - void PubSubClient::stop() - { - assert(this->started_); - - this->started_ = false; - } - - bool PubSubClient::listen(rapidjson::Document &message) - { - int numRequestedListens = message["data"]["topics"].Size(); - - if (this->numListens_ + numRequestedListens > MAX_PUBSUB_LISTENS) - { - // This PubSubClient is already at its peak listens - return false; - } - this->numListens_ += numRequestedListens; - DebugCount::increase("PubSub topic pending listens", - numRequestedListens); - - for (const auto &topic : message["data"]["topics"].GetArray()) - { - this->listeners_.emplace_back( - Listener{topic.GetString(), false, false, false}); - } - - auto nonce = generateUuid(); - rj::set(message, "nonce", nonce); - - QString payload = rj::stringify(message); - sentListens[nonce] = RequestMessage{payload, numRequestedListens}; - - this->send(payload.toUtf8()); - - return true; - } - - void PubSubClient::unlistenPrefix(const QString &prefix) - { - std::vector topics; - - for (auto it = this->listeners_.begin(); it != this->listeners_.end();) - { - const auto &listener = *it; - if (listener.topic.startsWith(prefix)) - { - topics.push_back(listener.topic); - it = this->listeners_.erase(it); - } - else - { - ++it; - } - } - - if (topics.empty()) - { - return; - } - - int numRequestedUnlistens = topics.size(); - - this->numListens_ -= numRequestedUnlistens; - DebugCount::increase("PubSub topic pending unlistens", - numRequestedUnlistens); - - auto message = createUnlistenMessage(topics); - - auto nonce = generateUuid(); - rj::set(message, "nonce", nonce); - - QString payload = rj::stringify(message); - sentUnlistens[nonce] = RequestMessage{payload, numRequestedUnlistens}; - - this->send(payload.toUtf8()); - } - - void PubSubClient::handlePong() - { - assert(this->awaitingPong_); - - this->awaitingPong_ = false; - } - - bool PubSubClient::isListeningToTopic(const QString &topic) - { - for (const auto &listener : this->listeners_) - { - if (listener.topic == topic) - { - return true; - } - } - - return false; - } - - void PubSubClient::ping() - { - assert(this->started_); - - if (!this->send(pingPayload)) - { - return; - } - - this->awaitingPong_ = true; - - auto self = this->shared_from_this(); - - runAfter(this->websocketClient_.get_io_service(), - std::chrono::seconds(15), [self](auto timer) { - if (!self->started_) - { - return; - } - - if (self->awaitingPong_) - { - qCDebug(chatterinoPubsub) - << "No pong response, disconnect!"; - // TODO(pajlada): Label this connection as "disconnect me" - } - }); - - runAfter(this->websocketClient_.get_io_service(), - std::chrono::minutes(5), [self](auto timer) { - if (!self->started_) - { - return; - } - - self->ping(); - }); - } - - bool PubSubClient::send(const char *payload) - { - WebsocketErrorCode ec; - this->websocketClient_.send(this->handle_, payload, - websocketpp::frame::opcode::text, ec); - - if (ec) - { - qCDebug(chatterinoPubsub) << "Error sending message" << payload - << ":" << ec.message().c_str(); - // TODO(pajlada): Check which error code happened and maybe - // gracefully handle it - - return false; - } - - return true; - } - -} // namespace detail - -PubSub::PubSub() -{ - qCDebug(chatterinoPubsub) << "init PubSub"; - - this->moderationActionHandlers["clear"] = [this](const auto &data, - const auto &roomID) { - ClearChatAction action(data, roomID); - - this->signals_.moderation.chatCleared.invoke(action); - }; - - this->moderationActionHandlers["slowoff"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::Slow; - action.state = ModeChangedAction::State::Off; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["slow"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::Slow; - action.state = ModeChangedAction::State::On; - - if (!data.HasMember("args")) - { - qCDebug(chatterinoPubsub) << "Missing required args member"; - return; - } - - const auto &args = data["args"]; - - if (!args.IsArray()) - { - qCDebug(chatterinoPubsub) << "args member must be an array"; - return; - } - - if (args.Size() == 0) - { - qCDebug(chatterinoPubsub) - << "Missing duration argument in slowmode on"; - return; - } - - const auto &durationArg = args[0]; - - if (!durationArg.IsString()) - { - qCDebug(chatterinoPubsub) << "Duration arg must be a string"; - return; - } - - bool ok; - - action.duration = QString(durationArg.GetString()).toUInt(&ok, 10); - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["r9kbetaoff"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::R9K; - action.state = ModeChangedAction::State::Off; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["r9kbeta"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::R9K; - action.state = ModeChangedAction::State::On; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["subscribersoff"] = - [this](const auto &data, const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::SubscribersOnly; - action.state = ModeChangedAction::State::Off; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["subscribers"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::SubscribersOnly; - action.state = ModeChangedAction::State::On; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["emoteonlyoff"] = - [this](const auto &data, const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::EmoteOnly; - action.state = ModeChangedAction::State::Off; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["emoteonly"] = [this](const auto &data, - const auto &roomID) { - ModeChangedAction action(data, roomID); - - action.mode = ModeChangedAction::Mode::EmoteOnly; - action.state = ModeChangedAction::State::On; - - this->signals_.moderation.modeChanged.invoke(action); - }; - - this->moderationActionHandlers["unmod"] = [this](const auto &data, - const auto &roomID) { - ModerationStateAction action(data, roomID); - - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - - action.modded = false; - - this->signals_.moderation.moderationStateChanged.invoke(action); - }; - - this->moderationActionHandlers["mod"] = [this](const auto &data, - const auto &roomID) { - ModerationStateAction action(data, roomID); - action.modded = true; - - QString innerType; - if (rj::getSafe(data, "type", innerType) && - innerType == "chat_login_moderation") - { - // Don't display the old message type - return; - } - - if (!getTargetUser(data, action.target)) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action mod: Unable to get " - "target_user_id"; - return; - } - - // Load target name from message.data.target_user_login - if (!getTargetUserName(data, action.target)) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action mod: Unable to get " - "target_user_name"; - return; - } - - this->signals_.moderation.moderationStateChanged.invoke(action); - }; - - this->moderationActionHandlers["timeout"] = [this](const auto &data, - const auto &roomID) { - BanAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 2) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - QString durationString; - if (!rj::getSafe(args[1], durationString)) - { - return; - } - bool ok; - action.duration = durationString.toUInt(&ok, 10); - - if (args.Size() >= 3) - { - if (!rj::getSafe(args[2], action.reason)) - { - return; - } - } - - this->signals_.moderation.userBanned.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["delete"] = [this](const auto &data, - const auto &roomID) { - DeleteAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 3) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - if (!rj::getSafe(args[1], action.messageText)) - { - return; - } - - if (!rj::getSafe(args[2], action.messageId)) - { - return; - } - - this->signals_.moderation.messageDeleted.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["ban"] = [this](const auto &data, - const auto &roomID) { - BanAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - if (args.Size() >= 2) - { - if (!rj::getSafe(args[1], action.reason)) - { - return; - } - } - - this->signals_.moderation.userBanned.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["unban"] = [this](const auto &data, - const auto &roomID) { - UnbanAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - action.previousState = UnbanAction::Banned; - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - this->signals_.moderation.userUnbanned.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["untimeout"] = [this](const auto &data, - const auto &roomID) { - UnbanAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - action.previousState = UnbanAction::TimedOut; - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - this->signals_.moderation.userUnbanned.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["automod_rejected"] = - [this](const auto &data, const auto &roomID) { - // Display the automod message and prompt the allow/deny - AutomodAction action(data, roomID); - - getCreatedByUser(data, action.source); - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - const auto &msgID = getMsgID(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.login)) - { - return; - } - - if (args.Size() >= 2) - { - if (!rj::getSafe(args[1], action.message)) - { - return; - } - } - - if (args.Size() >= 3) - { - if (!rj::getSafe(args[2], action.reason)) - { - return; - } - } - - if (!rj::getSafe(msgID, action.msgID)) - { - return; - } - - this->signals_.moderation.automodMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - - this->moderationActionHandlers["automod_message_rejected"] = - [this](const auto &data, const auto &roomID) { - AutomodInfoAction action(data, roomID); - action.type = AutomodInfoAction::OnHold; - this->signals_.moderation.automodInfoMessage.invoke(action); - }; - - this->moderationActionHandlers["automod_message_denied"] = - [this](const auto &data, const auto &roomID) { - AutomodInfoAction action(data, roomID); - action.type = AutomodInfoAction::Denied; - this->signals_.moderation.automodInfoMessage.invoke(action); - }; - - this->moderationActionHandlers["automod_message_approved"] = - [this](const auto &data, const auto &roomID) { - AutomodInfoAction action(data, roomID); - action.type = AutomodInfoAction::Approved; - this->signals_.moderation.automodInfoMessage.invoke(action); - }; - - this->channelTermsActionHandlers["add_permitted_term"] = - [this](const auto &data, const auto &roomID) { - // This term got a pass through automod - AutomodUserAction action(data, roomID); - getCreatedByUser(data, action.source); - - try - { - action.type = AutomodUserAction::AddPermitted; - if (!rj::getSafe(data, "text", action.message)) - { - return; - } - - if (!rj::getSafe(data, "requester_login", action.source.login)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing channel terms action:" << ex.what(); - } - }; - - this->channelTermsActionHandlers["add_blocked_term"] = - [this](const auto &data, const auto &roomID) { - // A term has been added - AutomodUserAction action(data, roomID); - getCreatedByUser(data, action.source); - - try - { - action.type = AutomodUserAction::AddBlocked; - if (!rj::getSafe(data, "text", action.message)) - { - return; - } - - if (!rj::getSafe(data, "requester_login", action.source.login)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing channel terms action:" << ex.what(); - } - }; - - this->moderationActionHandlers["delete_permitted_term"] = - [this](const auto &data, const auto &roomID) { - // This term got deleted - AutomodUserAction action(data, roomID); - getCreatedByUser(data, action.source); - - try - { - const auto &args = getArgs(data); - action.type = AutomodUserAction::RemovePermitted; - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.message)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - this->channelTermsActionHandlers["delete_permitted_term"] = - [this](const auto &data, const auto &roomID) { - // This term got deleted - AutomodUserAction action(data, roomID); - getCreatedByUser(data, action.source); - - try - { - action.type = AutomodUserAction::RemovePermitted; - if (!rj::getSafe(data, "text", action.message)) - { - return; - } - - if (!rj::getSafe(data, "requester_login", action.source.login)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing channel terms action:" << ex.what(); - } - }; - - this->moderationActionHandlers["delete_blocked_term"] = - [this](const auto &data, const auto &roomID) { - // This term got deleted - AutomodUserAction action(data, roomID); - - getCreatedByUser(data, action.source); - - try - { - const auto &args = getArgs(data); - action.type = AutomodUserAction::RemoveBlocked; - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.message)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing moderation action:" << ex.what(); - } - }; - this->channelTermsActionHandlers["delete_blocked_term"] = - [this](const auto &data, const auto &roomID) { - // This term got deleted - AutomodUserAction action(data, roomID); - - getCreatedByUser(data, action.source); - - try - { - action.type = AutomodUserAction::RemoveBlocked; - if (!rj::getSafe(data, "text", action.message)) - { - return; - } - - if (!rj::getSafe(data, "requester_login", action.source.login)) - { - return; - } - - this->signals_.moderation.automodUserMessage.invoke(action); - } - catch (const std::runtime_error &ex) - { - qCDebug(chatterinoPubsub) - << "Error parsing channel terms action:" << ex.what(); - } - }; - - // We don't get this one anymore or anything similiar - // We need some new topic so we can listen - // - //this->moderationActionHandlers["modified_automod_properties"] = - // [this](const auto &data, const auto &roomID) { - // // The automod settings got modified - // AutomodUserAction action(data, roomID); - // getCreatedByUser(data, action.source); - // action.type = AutomodUserAction::Properties; - // this->signals_.moderation.automodUserMessage.invoke(action); - // }; - - this->moderationActionHandlers["denied_automod_message"] = - [](const auto &data, const auto &roomID) { - // This message got denied by a moderator - // qCDebug(chatterinoPubsub) << rj::stringify(data); - }; - - this->moderationActionHandlers["approved_automod_message"] = - [](const auto &data, const auto &roomID) { - // This message got approved by a moderator - // qCDebug(chatterinoPubsub) << rj::stringify(data); - }; - - this->websocketClient.set_access_channels(websocketpp::log::alevel::all); - this->websocketClient.clear_access_channels( - websocketpp::log::alevel::frame_payload | - websocketpp::log::alevel::frame_header); - - this->websocketClient.init_asio(); - - // SSL Handshake - this->websocketClient.set_tls_init_handler( - bind(&PubSub::onTLSInit, this, ::_1)); - - this->websocketClient.set_message_handler( - bind(&PubSub::onMessage, this, ::_1, ::_2)); - this->websocketClient.set_open_handler( - bind(&PubSub::onConnectionOpen, this, ::_1)); - this->websocketClient.set_close_handler( - bind(&PubSub::onConnectionClose, this, ::_1)); - - // Add an initial client - this->addClient(); -} - -void PubSub::addClient() -{ - if (this->addingClient) - { - return; - } - - this->addingClient = true; - - websocketpp::lib::error_code ec; - auto con = this->websocketClient.get_connection(TWITCH_PUBSUB_URL, ec); - - if (ec) - { - qCDebug(chatterinoPubsub) - << "Unable to establish connection:" << ec.message().c_str(); - return; - } - - this->websocketClient.connect(con); -} - -void PubSub::start() -{ - this->mainThread.reset( - new std::thread(std::bind(&PubSub::runThread, this))); -} - -void PubSub::listenToWhispers(std::shared_ptr account) -{ - static const QString topicFormat("whispers.%1"); - - assert(account != nullptr); - - auto userID = account->getUserId(); - - qCDebug(chatterinoPubsub) << "Connection open!"; - websocketpp::lib::error_code ec; - - std::vector topics({topicFormat.arg(userID)}); - - this->listen(createListenMessage(topics, account)); - - if (ec) - { - qCDebug(chatterinoPubsub) - << "Unable to send message to websocket server:" - << ec.message().c_str(); - return; - } -} - -void PubSub::unlistenAllModerationActions() -{ - for (const auto &p : this->clients) - { - const auto &client = p.second; - client->unlistenPrefix("chat_moderator_actions."); - } -} - -void PubSub::listenToChannelModerationActions( - const QString &channelID, std::shared_ptr account) -{ - static const QString topicFormat("chat_moderator_actions.%1.%2"); - assert(!channelID.isEmpty()); - assert(account != nullptr); - QString userID = account->getUserId(); - if (userID.isEmpty()) - return; - - auto topic = topicFormat.arg(userID, channelID); - - if (this->isListeningToTopic(topic)) - { - return; - } - - qCDebug(chatterinoPubsub) << "Listen to topic" << topic; - - this->listenToTopic(topic, account); -} - -void PubSub::listenToAutomod(const QString &channelID, - std::shared_ptr account) -{ - static const QString topicFormat("automod-queue.%1.%2"); - assert(!channelID.isEmpty()); - assert(account != nullptr); - QString userID = account->getUserId(); - if (userID.isEmpty()) - return; - - auto topic = topicFormat.arg(userID, channelID); - - if (this->isListeningToTopic(topic)) - { - return; - } - - qCDebug(chatterinoPubsub) << "Listen to topic" << topic; - - this->listenToTopic(topic, account); -} - -void PubSub::listenToChannelPointRewards(const QString &channelID, - std::shared_ptr account) -{ - static const QString topicFormat("community-points-channel-v1.%1"); - assert(!channelID.isEmpty()); - assert(account != nullptr); - - auto topic = topicFormat.arg(channelID); - - if (this->isListeningToTopic(topic)) - { - return; - } - qCDebug(chatterinoPubsub) << "Listen to topic" << topic; - - this->listenToTopic(topic, account); -} - -void PubSub::listenToTopic(const QString &topic, - std::shared_ptr account) -{ - auto message = createListenMessage({topic}, account); - - this->listen(std::move(message)); -} - -void PubSub::listen(rapidjson::Document &&msg) -{ - if (this->tryListen(msg)) - { - return; - } - - this->addClient(); - - this->requests.emplace_back( - std::make_unique(std::move(msg))); - - DebugCount::increase("PubSub topic backlog"); -} - -bool PubSub::tryListen(rapidjson::Document &msg) -{ - for (const auto &p : this->clients) - { - const auto &client = p.second; - if (client->listen(msg)) - { - return true; - } - } - - return false; -} - -bool PubSub::isListeningToTopic(const QString &topic) -{ - for (const auto &p : this->clients) - { - const auto &client = p.second; - if (client->isListeningToTopic(topic)) - { - return true; - } - } - - return false; -} - -void PubSub::onMessage(websocketpp::connection_hdl hdl, - WebsocketMessagePtr websocketMessage) -{ - const auto &payload = - QString::fromStdString(websocketMessage->get_payload()); - - rapidjson::Document msg; - - rapidjson::ParseResult res = msg.Parse(payload.toUtf8()); - - if (!res) - { - qCDebug(chatterinoPubsub) - << QString("Error parsing message '%1' from PubSub: %2") - .arg(payload, rapidjson::GetParseError_En(res.Code())); - return; - } - - if (!msg.IsObject()) - { - qCDebug(chatterinoPubsub) - << QString("Error parsing message '%1' from PubSub. Root object is " - "not an object") - .arg(payload); - return; - } - - QString type; - - if (!rj::getSafe(msg, "type", type)) - { - qCDebug(chatterinoPubsub) - << "Missing required string member `type` in message root"; - return; - } - - if (type == "RESPONSE") - { - this->handleResponse(msg); - } - else if (type == "MESSAGE") - { - if (!msg.HasMember("data")) - { - qCDebug(chatterinoPubsub) - << "Missing required object member `data` in message root"; - return; - } - - const auto &data = msg["data"]; - - if (!data.IsObject()) - { - qCDebug(chatterinoPubsub) << "Member `data` must be an object"; - return; - } - - this->handleMessageResponse(data); - } - else if (type == "PONG") - { - auto clientIt = this->clients.find(hdl); - - // If this assert goes off, there's something wrong with the connection - // creation/preserving code KKona - assert(clientIt != this->clients.end()); - - auto &client = *clientIt; - - client.second->handlePong(); - } - else - { - qCDebug(chatterinoPubsub) << "Unknown message type:" << type; - } -} - -void PubSub::onConnectionOpen(WebsocketHandle hdl) -{ - DebugCount::increase("PubSub connections"); - this->addingClient = false; - - auto client = - std::make_shared(this->websocketClient, hdl); - - // We separate the starting from the constructor because we will want to use - // shared_from_this - client->start(); - - this->clients.emplace(hdl, client); - - this->connected.invoke(); - - for (auto it = this->requests.begin(); it != this->requests.end();) - { - const auto &request = *it; - if (client->listen(*request)) - { - DebugCount::decrease("PubSub topic backlog"); - it = this->requests.erase(it); - } - else - { - ++it; - } - } - - if (!this->requests.empty()) - { - this->addClient(); - } -} - -void PubSub::onConnectionClose(WebsocketHandle hdl) -{ - DebugCount::decrease("PubSub connections"); - auto clientIt = this->clients.find(hdl); - - // If this assert goes off, there's something wrong with the connection - // creation/preserving code KKona - assert(clientIt != this->clients.end()); - - auto &client = clientIt->second; - - client->stop(); - - this->clients.erase(clientIt); - - this->connected.invoke(); -} - -PubSub::WebsocketContextPtr PubSub::onTLSInit(websocketpp::connection_hdl hdl) -{ - WebsocketContextPtr ctx( - new boost::asio::ssl::context(boost::asio::ssl::context::tlsv12)); - - try - { - ctx->set_options(boost::asio::ssl::context::default_workarounds | - boost::asio::ssl::context::no_sslv2 | - boost::asio::ssl::context::single_dh_use); - } - catch (const std::exception &e) - { - qCDebug(chatterinoPubsub) - << "Exception caught in OnTLSInit:" << e.what(); - } - - return ctx; -} - -void PubSub::handleResponse(const rapidjson::Document &msg) -{ - QString error; - - if (!rj::getSafe(msg, "error", error)) - return; - - QString nonce; - rj::getSafe(msg, "nonce", nonce); - - const bool failed = !error.isEmpty(); - - if (failed) - { - qCDebug(chatterinoPubsub) - << QString("Error %1 on nonce %2").arg(error, nonce); - } - - if (auto it = sentListens.find(nonce); it != sentListens.end()) - { - this->handleListenResponse(it->second, failed); - return; - } - - if (auto it = sentUnlistens.find(nonce); it != sentUnlistens.end()) - { - this->handleUnlistenResponse(it->second, failed); - return; - } - - qCDebug(chatterinoPubsub) - << "Response on unused" << nonce << "client/topic listener mismatch?"; -} - -void PubSub::handleListenResponse(const RequestMessage &msg, bool failed) -{ - DebugCount::decrease("PubSub topic pending listens", msg.topicCount); - if (failed) - { - DebugCount::increase("PubSub topic failed listens", msg.topicCount); - } - else - { - DebugCount::increase("PubSub topic listening", msg.topicCount); - } -} - -void PubSub::handleUnlistenResponse(const RequestMessage &msg, bool failed) -{ - DebugCount::decrease("PubSub topic pending unlistens", msg.topicCount); - if (failed) - { - DebugCount::increase("PubSub topic failed unlistens", msg.topicCount); - } - else - { - DebugCount::decrease("PubSub topic listening", msg.topicCount); - } -} - -void PubSub::handleMessageResponse(const rapidjson::Value &outerData) -{ - QString topic; - qCDebug(chatterinoPubsub) << rj::stringify(outerData); - - if (!rj::getSafe(outerData, "topic", topic)) - { - qCDebug(chatterinoPubsub) - << "Missing required string member `topic` in outerData"; - return; - } - - QString payload; - - if (!rj::getSafe(outerData, "message", payload)) - { - qCDebug(chatterinoPubsub) << "Expected string message in outerData"; - return; - } - - rapidjson::Document msg; - - rapidjson::ParseResult res = msg.Parse(payload.toUtf8()); - - if (!res) - { - qCDebug(chatterinoPubsub) - << QString("Error parsing message '%1' from PubSub: %2") - .arg(payload, rapidjson::GetParseError_En(res.Code())); - return; - } - - if (topic.startsWith("whispers.")) - { - QString whisperType; - - if (!rj::getSafe(msg, "type", whisperType)) - { - qCDebug(chatterinoPubsub) << "Bad whisper data"; - return; - } - - if (whisperType == "whisper_received") - { - this->signals_.whisper.received.invoke(msg); - } - else if (whisperType == "whisper_sent") - { - this->signals_.whisper.sent.invoke(msg); - } - else if (whisperType == "thread") - { - // Handle thread? - } - else - { - qCDebug(chatterinoPubsub) << "Invalid whisper type:" << whisperType; - return; - } - } - else if (topic.startsWith("chat_moderator_actions.")) - { - auto topicParts = topic.split("."); - assert(topicParts.length() == 3); - const auto &data = msg["data"]; - - QString moderationEventType; - - if (!rj::getSafe(msg, "type", moderationEventType)) - { - qCDebug(chatterinoPubsub) << "Bad moderator event data"; - return; - } - if (moderationEventType == "moderation_action") - { - QString moderationAction; - - if (!rj::getSafe(data, "moderation_action", moderationAction)) - { - qCDebug(chatterinoPubsub) - << "Missing moderation action in data:" - << rj::stringify(data); - return; - } - - auto handlerIt = - this->moderationActionHandlers.find(moderationAction); - - if (handlerIt == this->moderationActionHandlers.end()) - { - qCDebug(chatterinoPubsub) - << "No handler found for moderation action" - << moderationAction; - return; - } - // Invoke handler function - handlerIt->second(data, topicParts[2]); - } - else if (moderationEventType == "channel_terms_action") - { - QString channelTermsAction; - - if (!rj::getSafe(data, "type", channelTermsAction)) - { - qCDebug(chatterinoPubsub) - << "Missing channel terms action in data:" - << rj::stringify(data); - return; - } - - auto handlerIt = - this->channelTermsActionHandlers.find(channelTermsAction); - - if (handlerIt == this->channelTermsActionHandlers.end()) - { - qCDebug(chatterinoPubsub) - << "No handler found for channel terms action" - << channelTermsAction; - return; - } - // Invoke handler function - handlerIt->second(data, topicParts[2]); - } - } - else if (topic.startsWith("community-points-channel-v1.")) - { - QString pointEventType; - if (!rj::getSafe(msg, "type", pointEventType)) - { - qCDebug(chatterinoPubsub) << "Bad channel point event data"; - return; - } - - if (pointEventType == "reward-redeemed") - { - if (!rj::getSafeObject(msg, "data", msg)) - { - qCDebug(chatterinoPubsub) - << "No data found for redeemed reward"; - return; - } - if (!rj::getSafeObject(msg, "redemption", msg)) - { - qCDebug(chatterinoPubsub) - << "No redemption info found for redeemed reward"; - return; - } - this->signals_.pointReward.redeemed.invoke(msg); - } - else - { - qCDebug(chatterinoPubsub) - << "Invalid point event type:" << pointEventType; - } - } - else if (topic.startsWith("automod-queue.")) - { - auto topicParts = topic.split("."); - assert(topicParts.length() == 3); - auto &data = msg["data"]; - - QString automodEventType; - if (!rj::getSafe(msg, "type", automodEventType)) - { - qCDebug(chatterinoPubsub) << "Bad automod event data"; - return; - } - - if (automodEventType == "automod_caught_message") - { - QString status; - if (!rj::getSafe(data, "status", status)) - { - qCDebug(chatterinoPubsub) << "Failed to get status"; - return; - } - if (status == "PENDING") - { - AutomodAction action(data, topicParts[2]); - rapidjson::Value classification; - if (!rj::getSafeObject(data, "content_classification", - classification)) - { - qCDebug(chatterinoPubsub) - << "Failed to get content_classification"; - return; - } - - QString contentCategory; - if (!rj::getSafe(classification, "category", contentCategory)) - { - qCDebug(chatterinoPubsub) - << "Failed to get content category"; - return; - } - int contentLevel; - if (!rj::getSafe(classification, "level", contentLevel)) - { - qCDebug(chatterinoPubsub) << "Failed to get content level"; - return; - } - action.reason = QString("%1 level %2") - .arg(contentCategory) - .arg(contentLevel); - - rapidjson::Value messageData; - if (!rj::getSafeObject(data, "message", messageData)) - { - qCDebug(chatterinoPubsub) << "Failed to get message data"; - return; - } - - rapidjson::Value messageContent; - if (!rj::getSafeObject(messageData, "content", messageContent)) - { - qCDebug(chatterinoPubsub) - << "Failed to get message content"; - return; - } - if (!rj::getSafe(messageData, "id", action.msgID)) - { - qCDebug(chatterinoPubsub) << "Failed to get message id"; - return; - } - - if (!rj::getSafe(messageContent, "text", action.message)) - { - qCDebug(chatterinoPubsub) << "Failed to get message text"; - return; - } - - // this message also contains per-word automod data, which could be implemented - - // extract sender data manually because Twitch loves not being consistent - rapidjson::Value senderData; - if (!rj::getSafeObject(messageData, "sender", senderData)) - { - qCDebug(chatterinoPubsub) << "Failed to get sender"; - return; - } - QString senderId; - if (!rj::getSafe(senderData, "user_id", senderId)) - { - qCDebug(chatterinoPubsub) << "Failed to get sender user id"; - return; - } - QString senderLogin; - if (!rj::getSafe(senderData, "login", senderLogin)) - { - qCDebug(chatterinoPubsub) << "Failed to get sender login"; - return; - } - QString senderDisplayName = senderLogin; - bool hasLocalizedName = false; - if (rj::getSafe(senderData, "display_name", senderDisplayName)) - { - // check for non-ascii display names - if (QString::compare(senderLogin, senderDisplayName, - Qt::CaseInsensitive) != 0) - { - hasLocalizedName = true; - } - } - QColor senderColor; - QString senderColor_; - if (rj::getSafe(senderData, "chat_color", senderColor_)) - { - senderColor = QColor(senderColor_); - } - else if (getSettings()->colorizeNicknames) - { - // color may be not present if user is a grey-name - senderColor = getRandomColor(senderId); - } - // handle username style based on prefered setting - switch (getSettings()->usernameDisplayMode.getValue()) - { - case UsernameDisplayMode::Username: { - if (hasLocalizedName) - { - senderDisplayName = senderLogin; - } - break; - } - case UsernameDisplayMode::LocalizedName: { - break; - } - case UsernameDisplayMode::UsernameAndLocalizedName: { - if (hasLocalizedName) - { - senderDisplayName = QString("%1(%2)").arg( - senderLogin, senderDisplayName); - } - break; - } - } - - action.target = ActionUser{senderId, senderLogin, - senderDisplayName, senderColor}; - this->signals_.moderation.automodMessage.invoke(action); - } - // "ALLOWED" and "DENIED" statuses remain unimplemented - // They are versions of automod_message_(denied|approved) but for mods. - } - } - else - { - qCDebug(chatterinoPubsub) << "Unknown topic:" << topic; - return; - } -} - -void PubSub::runThread() -{ - qCDebug(chatterinoPubsub) << "Start pubsub manager thread"; - this->websocketClient.run(); - qCDebug(chatterinoPubsub) << "Done with pubsub manager thread"; -} - -} // namespace chatterino diff --git a/src/providers/twitch/PubsubClient.hpp b/src/providers/twitch/PubsubClient.hpp deleted file mode 100644 index 06c8d50b8..000000000 --- a/src/providers/twitch/PubsubClient.hpp +++ /dev/null @@ -1,209 +0,0 @@ -#pragma once - -#include "providers/twitch/ChatterinoWebSocketppLogger.hpp" -#include "providers/twitch/PubsubActions.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace chatterino { - -struct chatterinoconfig : public websocketpp::config::asio_tls_client { - typedef websocketpp::log::chatterinowebsocketpplogger< - concurrency_type, websocketpp::log::elevel> - elog_type; - typedef websocketpp::log::chatterinowebsocketpplogger< - concurrency_type, websocketpp::log::alevel> - alog_type; - - struct permessage_deflate_config { - }; - - typedef websocketpp::extensions::permessage_deflate::disabled< - permessage_deflate_config> - permessage_deflate_type; -}; - -using WebsocketClient = websocketpp::client; -using WebsocketHandle = websocketpp::connection_hdl; -using WebsocketErrorCode = websocketpp::lib::error_code; - -#define MAX_PUBSUB_LISTENS 50 -#define MAX_PUBSUB_CONNECTIONS 10 - -struct RequestMessage { - QString payload; - int topicCount; -}; - -namespace detail { - - struct Listener { - QString topic; - bool authed; - bool persistent; - bool confirmed = false; - }; - - class PubSubClient : public std::enable_shared_from_this - { - public: - PubSubClient(WebsocketClient &_websocketClient, - WebsocketHandle _handle); - - void start(); - void stop(); - - bool listen(rapidjson::Document &message); - void unlistenPrefix(const QString &prefix); - - void handlePong(); - - bool isListeningToTopic(const QString &topic); - - private: - void ping(); - bool send(const char *payload); - - WebsocketClient &websocketClient_; - WebsocketHandle handle_; - uint16_t numListens_ = 0; - - std::vector listeners_; - - std::atomic awaitingPong_{false}; - std::atomic started_{false}; - }; - -} // namespace detail - -class PubSub -{ - using WebsocketMessagePtr = - websocketpp::config::asio_tls_client::message_type::ptr; - using WebsocketContextPtr = - websocketpp::lib::shared_ptr; - - template - using Signal = - pajlada::Signals::Signal; // type-id is vector> - - WebsocketClient websocketClient; - std::unique_ptr mainThread; - -public: - PubSub(); - - ~PubSub() = delete; - - enum class State { - Connected, - Disconnected, - }; - - void start(); - - bool isConnected() const - { - return this->state == State::Connected; - } - - pajlada::Signals::NoArgSignal connected; - - struct { - struct { - Signal chatCleared; - Signal messageDeleted; - Signal modeChanged; - Signal moderationStateChanged; - - Signal userBanned; - Signal userUnbanned; - - Signal automodMessage; - Signal automodUserMessage; - Signal automodInfoMessage; - } moderation; - - struct { - // Parsing should be done in PubSubManager as well, - // but for now we just send the raw data - Signal received; - Signal sent; - } whisper; - - struct { - Signal redeemed; - } pointReward; - } signals_; - - void listenToWhispers(std::shared_ptr account); - - void unlistenAllModerationActions(); - - void listenToChannelModerationActions( - const QString &channelID, std::shared_ptr account); - void listenToAutomod(const QString &channelID, - std::shared_ptr account); - - void listenToChannelPointRewards(const QString &channelID, - std::shared_ptr account); - - std::vector> requests; - -private: - void listenToTopic(const QString &topic, - std::shared_ptr account); - - void listen(rapidjson::Document &&msg); - bool tryListen(rapidjson::Document &msg); - - bool isListeningToTopic(const QString &topic); - - void addClient(); - std::atomic addingClient{false}; - - State state = State::Connected; - - std::map, - std::owner_less> - clients; - - std::unordered_map< - QString, std::function> - moderationActionHandlers; - - std::unordered_map< - QString, std::function> - channelTermsActionHandlers; - - void onMessage(websocketpp::connection_hdl hdl, WebsocketMessagePtr msg); - void onConnectionOpen(websocketpp::connection_hdl hdl); - void onConnectionClose(websocketpp::connection_hdl hdl); - WebsocketContextPtr onTLSInit(websocketpp::connection_hdl hdl); - - void handleResponse(const rapidjson::Document &msg); - void handleListenResponse(const RequestMessage &msg, bool failed); - void handleUnlistenResponse(const RequestMessage &msg, bool failed); - void handleMessageResponse(const rapidjson::Value &data); - - void runThread(); -}; - -} // namespace chatterino diff --git a/src/providers/twitch/PubsubHelpers.cpp b/src/providers/twitch/PubsubHelpers.cpp deleted file mode 100644 index e3ea28561..000000000 --- a/src/providers/twitch/PubsubHelpers.cpp +++ /dev/null @@ -1,104 +0,0 @@ -#include "providers/twitch/PubsubHelpers.hpp" - -#include "providers/twitch/PubsubActions.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "util/RapidjsonHelpers.hpp" - -namespace chatterino { - -const rapidjson::Value &getArgs(const rapidjson::Value &data) -{ - if (!data.HasMember("args")) - { - throw std::runtime_error("Missing member args"); - } - - const auto &args = data["args"]; - - if (!args.IsArray()) - { - throw std::runtime_error("args must be an array"); - } - - return args; -} - -const rapidjson::Value &getMsgID(const rapidjson::Value &data) -{ - if (!data.HasMember("msg_id")) - { - throw std::runtime_error("Missing member msg_id"); - } - - const auto &msgID = data["msg_id"]; - - return msgID; -} - -bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user) -{ - return rj::getSafe(data, "created_by", user.login) && - rj::getSafe(data, "created_by_user_id", user.id); -} - -bool getTargetUser(const rapidjson::Value &data, ActionUser &user) -{ - return rj::getSafe(data, "target_user_id", user.id); -} - -bool getTargetUserName(const rapidjson::Value &data, ActionUser &user) -{ - return rj::getSafe(data, "target_user_login", user.login); -} - -rapidjson::Document createListenMessage(const std::vector &topicsVec, - std::shared_ptr account) -{ - rapidjson::Document msg(rapidjson::kObjectType); - auto &a = msg.GetAllocator(); - - rj::set(msg, "type", "LISTEN"); - - rapidjson::Value data(rapidjson::kObjectType); - - if (account) - { - rj::set(data, "auth_token", account->getOAuthToken(), a); - } - - rapidjson::Value topics(rapidjson::kArrayType); - for (const auto &topic : topicsVec) - { - rj::add(topics, topic, a); - } - - rj::set(data, "topics", topics, a); - - rj::set(msg, "data", data); - - return msg; -} - -rapidjson::Document createUnlistenMessage(const std::vector &topicsVec) -{ - rapidjson::Document msg(rapidjson::kObjectType); - auto &a = msg.GetAllocator(); - - rj::set(msg, "type", "UNLISTEN"); - - rapidjson::Value data(rapidjson::kObjectType); - - rapidjson::Value topics(rapidjson::kArrayType); - for (const auto &topic : topicsVec) - { - rj::add(topics, topic, a); - } - - rj::set(data, "topics", topics, a); - - rj::set(msg, "data", data); - - return msg; -} - -} // namespace chatterino diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index d9517ee75..573ba7906 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -119,7 +119,7 @@ void TwitchAccountManager::reloadUsers() qCDebug(chatterinoTwitch) << "It was the current user, so we need to " "reconnect stuff!"; - this->currentUserChanged.invoke(); + this->currentUserChanged(); } } break; @@ -156,7 +156,7 @@ void TwitchAccountManager::load() this->currentUser_ = this->anonymousUser_; } - this->currentUserChanged.invoke(); + this->currentUserChanged(); }); } diff --git a/src/providers/twitch/TwitchAccountManager.hpp b/src/providers/twitch/TwitchAccountManager.hpp index 5956fdf00..eaa303c1a 100644 --- a/src/providers/twitch/TwitchAccountManager.hpp +++ b/src/providers/twitch/TwitchAccountManager.hpp @@ -5,6 +5,8 @@ #include "providers/twitch/TwitchAccount.hpp" #include "util/SharedPtrElementLess.hpp" +#include + #include #include @@ -48,7 +50,8 @@ public: pajlada::Settings::Setting currentUsername{"/accounts/current", ""}; - pajlada::Signals::NoArgSignal currentUserChanged; + // pajlada::Signals::NoArgSignal currentUserChanged; + boost::signals2::signal currentUserChanged; pajlada::Signals::NoArgSignal userListUpdated; SignalVector> accounts; diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index f88711545..ce8bc10d1 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -11,8 +11,9 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/LoadBttvChannelEmote.hpp" #include "providers/twitch/IrcMessageHandler.hpp" -#include "providers/twitch/PubsubClient.hpp" +#include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "singletons/Emotes.hpp" @@ -159,24 +160,20 @@ TwitchChannel::TwitchChannel(const QString &name) { qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened"; - this->signalHolder_.managedConnect( - getApp()->accounts->twitch.currentUserChanged, [=] { + this->bSignals_.emplace_back( + getApp()->accounts->twitch.currentUserChanged.connect([=] { this->setMod(false); - }); + this->refreshPubSub(); + })); - // pubsub - this->signalHolder_.managedConnect( - getApp()->accounts->twitch.currentUserChanged, [=] { - this->refreshPubsub(); - }); - this->refreshPubsub(); + this->refreshPubSub(); this->userStateChanged.connect([this] { - this->refreshPubsub(); + this->refreshPubSub(); }); // room id loaded -> refresh live status this->roomIdChanged.connect([this]() { - this->refreshPubsub(); + this->refreshPubSub(); this->refreshTitle(); this->refreshLiveStatus(); this->refreshBadges(); @@ -281,11 +278,6 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) { assertInGuiThread(); - if (!reward.hasParsedSuccessfully) - { - return; - } - if (!reward.isUserInputRequired) { MessageBuilder builder; @@ -295,7 +287,7 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) return; } - bool result; + bool result = false; { auto channelPointRewards = this->channelPointRewards_.access(); result = channelPointRewards->try_emplace(reward.id, reward).second; @@ -847,16 +839,21 @@ void TwitchChannel::loadRecentMessages() .execute(); } -void TwitchChannel::refreshPubsub() +void TwitchChannel::refreshPubSub() { auto roomId = this->roomId(); if (roomId.isEmpty()) + { return; + } - auto account = getApp()->accounts->twitch.getCurrent(); - getApp()->twitch->pubsub->listenToChannelModerationActions(roomId, account); - getApp()->twitch->pubsub->listenToAutomod(roomId, account); - getApp()->twitch->pubsub->listenToChannelPointRewards(roomId, account); + auto currentAccount = getApp()->accounts->twitch.getCurrent(); + + getApp()->twitch->pubsub->setAccount(currentAccount); + + getApp()->twitch->pubsub->listenToChannelModerationActions(roomId); + getApp()->twitch->pubsub->listenToAutomod(roomId); + getApp()->twitch->pubsub->listenToChannelPointRewards(roomId); } void TwitchChannel::refreshChatters() diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 9c7ecb258..c6264d3c8 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -144,7 +145,7 @@ private: // Methods void refreshLiveStatus(); void parseLiveStatus(bool live, const HelixStream &stream); - void refreshPubsub(); + void refreshPubSub(); void refreshChatters(); void refreshBadges(); void refreshCheerEmotes(); @@ -199,6 +200,7 @@ private: bool isClipCreationInProgress{false}; pajlada::Signals::SignalHolder signalHolder_; + std::vector bSignals_; friend class TwitchIrcServer; friend class TwitchMessageBuilder; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 8a0aa1642..5c4da60a0 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -11,7 +11,7 @@ #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/IrcMessageHandler.hpp" -#include "providers/twitch/PubsubClient.hpp" +#include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchHelpers.hpp" @@ -22,6 +22,8 @@ // using namespace Communi; using namespace std::chrono_literals; +#define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv" + namespace chatterino { TwitchIrcServer::TwitchIrcServer() @@ -32,7 +34,7 @@ TwitchIrcServer::TwitchIrcServer() { this->initializeIrc(); - this->pubsub = new PubSub; + this->pubsub = new PubSub(TWITCH_PUBSUB_URL); // getSettings()->twitchSeperateWriteConnection.connect([this](auto, auto) { // this->connect(); }, @@ -45,6 +47,7 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths) getApp()->accounts->twitch.currentUserChanged.connect([this]() { postToThread([this] { this->connect(); + this->pubsub->setAccount(getApp()->accounts->twitch.getCurrent()); }); }); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index a7013c23f..0d4091d50 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -4,7 +4,7 @@ #include "common/Outcome.hpp" #include "messages/SharedMessageBuilder.hpp" #include "providers/twitch/ChannelPointReward.hpp" -#include "providers/twitch/PubsubActions.hpp" +#include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchBadge.hpp" #include diff --git a/src/providers/twitch/pubsubmessages/AutoMod.cpp b/src/providers/twitch/pubsubmessages/AutoMod.cpp new file mode 100644 index 000000000..8c0838f6b --- /dev/null +++ b/src/providers/twitch/pubsubmessages/AutoMod.cpp @@ -0,0 +1,40 @@ +#include "providers/twitch/pubsubmessages/AutoMod.hpp" + +namespace chatterino { + +PubSubAutoModQueueMessage::PubSubAutoModQueueMessage(const QJsonObject &root) + : typeString(root.value("type").toString()) + , data(root.value("data").toObject()) + , status(this->data.value("status").toString()) +{ + auto oType = magic_enum::enum_cast(this->typeString.toStdString()); + if (oType.has_value()) + { + this->type = oType.value(); + } + + auto contentClassification = + data.value("content_classification").toObject(); + + this->contentCategory = contentClassification.value("category").toString(); + this->contentLevel = contentClassification.value("level").toInt(); + + auto message = data.value("message").toObject(); + + this->messageID = message.value("id").toString(); + + auto messageContent = message.value("content").toObject(); + + this->messageText = messageContent.value("text").toString(); + + auto messageSender = message.value("sender").toObject(); + + this->senderUserID = messageSender.value("user_id").toString(); + this->senderUserLogin = messageSender.value("login").toString(); + this->senderUserDisplayName = + messageSender.value("display_name").toString(); + this->senderUserChatColor = + QColor(messageSender.value("chat_color").toString()); +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/AutoMod.hpp b/src/providers/twitch/pubsubmessages/AutoMod.hpp new file mode 100644 index 000000000..f44bd0082 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/AutoMod.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include + +namespace chatterino { + +struct PubSubAutoModQueueMessage { + enum class Type { + AutoModCaughtMessage, + + INVALID, + }; + QString typeString; + Type type = Type::INVALID; + + QJsonObject data; + + QString status; + + QString contentCategory; + int contentLevel; + + QString messageID; + QString messageText; + + QString senderUserID; + QString senderUserLogin; + QString senderUserDisplayName; + QColor senderUserChatColor; + + PubSubAutoModQueueMessage(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubAutoModQueueMessage::Type>( + chatterino::PubSubAutoModQueueMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubAutoModQueueMessage::Type::AutoModCaughtMessage: + return "automod_caught_message"; + + default: + return default_tag; + } +} diff --git a/src/providers/twitch/pubsubmessages/Base.cpp b/src/providers/twitch/pubsubmessages/Base.cpp new file mode 100644 index 000000000..fd921e765 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Base.cpp @@ -0,0 +1,19 @@ +#include "providers/twitch/pubsubmessages/Base.hpp" + +namespace chatterino { + +PubSubMessage::PubSubMessage(QJsonObject _object) + + : object(std::move(_object)) + , nonce(this->object.value("nonce").toString()) + , error(this->object.value("error").toString()) + , typeString(this->object.value("type").toString()) +{ + auto oType = magic_enum::enum_cast(this->typeString.toStdString()); + if (oType.has_value()) + { + this->type = oType.value(); + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Base.hpp b/src/providers/twitch/pubsubmessages/Base.hpp new file mode 100644 index 000000000..3c8f28c01 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Base.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace chatterino { + +struct PubSubMessage { + enum class Type { + Pong, + Response, + Message, + + INVALID, + }; + + QJsonObject object; + + QString nonce; + QString error; + QString typeString; + Type type; + + PubSubMessage(QJsonObject _object); + + template + boost::optional toInner(); +}; + +template +boost::optional PubSubMessage::toInner() +{ + auto dataValue = this->object.value("data"); + if (!dataValue.isObject()) + { + return boost::none; + } + + auto data = dataValue.toObject(); + + return InnerClass{this->nonce, data}; +} + +static boost::optional parsePubSubBaseMessage( + const QString &blob) +{ + QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); + + if (jsonDoc.isNull()) + { + return boost::none; + } + + return PubSubMessage(jsonDoc.object()); +} + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::PubSubMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubMessage::Type::Pong: + return "PONG"; + + case chatterino::PubSubMessage::Type::Response: + return "RESPONSE"; + + case chatterino::PubSubMessage::Type::Message: + return "MESSAGE"; + + default: + return default_tag; + } +} diff --git a/src/providers/twitch/pubsubmessages/ChannelPoints.cpp b/src/providers/twitch/pubsubmessages/ChannelPoints.cpp new file mode 100644 index 000000000..8907a2d2e --- /dev/null +++ b/src/providers/twitch/pubsubmessages/ChannelPoints.cpp @@ -0,0 +1,17 @@ +#include "providers/twitch/pubsubmessages/ChannelPoints.hpp" + +namespace chatterino { + +PubSubCommunityPointsChannelV1Message::PubSubCommunityPointsChannelV1Message( + const QJsonObject &root) + : typeString(root.value("type").toString()) + , data(root.value("data").toObject()) +{ + auto oType = magic_enum::enum_cast(this->typeString.toStdString()); + if (oType.has_value()) + { + this->type = oType.value(); + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/ChannelPoints.hpp b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp new file mode 100644 index 000000000..68b9a23f2 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include + +namespace chatterino { + +struct PubSubCommunityPointsChannelV1Message { + enum class Type { + RewardRedeemed, + + INVALID, + }; + + QString typeString; + Type type = Type::INVALID; + + QJsonObject data; + + PubSubCommunityPointsChannelV1Message(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubCommunityPointsChannelV1Message::Type>( + chatterino::PubSubCommunityPointsChannelV1Message::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubCommunityPointsChannelV1Message::Type:: + RewardRedeemed: + return "reward-redeemed"; + default: + return default_tag; + } +} diff --git a/src/providers/twitch/pubsubmessages/ChatModeratorAction.cpp b/src/providers/twitch/pubsubmessages/ChatModeratorAction.cpp new file mode 100644 index 000000000..8134178c5 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/ChatModeratorAction.cpp @@ -0,0 +1,17 @@ +#include "providers/twitch/pubsubmessages/ChatModeratorAction.hpp" + +namespace chatterino { + +PubSubChatModeratorActionMessage::PubSubChatModeratorActionMessage( + const QJsonObject &root) + : typeString(root.value("type").toString()) + , data(root.value("data").toObject()) +{ + auto oType = magic_enum::enum_cast(this->typeString.toStdString()); + if (oType.has_value()) + { + this->type = oType.value(); + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp b/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp new file mode 100644 index 000000000..bd2038e6b --- /dev/null +++ b/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include + +namespace chatterino { + +struct PubSubChatModeratorActionMessage { + enum class Type { + ModerationAction, + ChannelTermsAction, + + INVALID, + }; + + QString typeString; + Type type = Type::INVALID; + + QJsonObject data; + + PubSubChatModeratorActionMessage(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubChatModeratorActionMessage::Type>( + chatterino::PubSubChatModeratorActionMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubChatModeratorActionMessage::Type:: + ModerationAction: + return "moderation_action"; + + case chatterino::PubSubChatModeratorActionMessage::Type:: + ChannelTermsAction: + return "channel_terms_action"; + + default: + return default_tag; + } +} diff --git a/src/providers/twitch/pubsubmessages/Listen.cpp b/src/providers/twitch/pubsubmessages/Listen.cpp new file mode 100644 index 000000000..959333a3e --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Listen.cpp @@ -0,0 +1,50 @@ +#include "providers/twitch/pubsubmessages/Listen.hpp" + +#include "util/Helpers.hpp" + +#include +#include +#include + +namespace chatterino { + +PubSubListenMessage::PubSubListenMessage(std::vector _topics) + : topics(std::move(_topics)) + , nonce(generateUuid()) +{ +} + +void PubSubListenMessage::setToken(const QString &_token) +{ + this->token = _token; +} + +QByteArray PubSubListenMessage::toJson() const +{ + QJsonObject root; + + root["type"] = "LISTEN"; + root["nonce"] = this->nonce; + + { + QJsonObject data; + + QJsonArray jsonTopics; + + std::copy(this->topics.begin(), this->topics.end(), + std::back_inserter(jsonTopics)); + + data["topics"] = jsonTopics; + + if (!this->token.isEmpty()) + { + data["auth_token"] = this->token; + } + + root["data"] = data; + } + + return QJsonDocument(root).toJson(); +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Listen.hpp b/src/providers/twitch/pubsubmessages/Listen.hpp new file mode 100644 index 000000000..fbab60dc7 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Listen.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include + +namespace chatterino { + +// PubSubListenMessage is an outgoing LISTEN message that is sent for the client to subscribe to a list of topics +struct PubSubListenMessage { + const std::vector topics; + + const QString nonce; + + QString token; + + PubSubListenMessage(std::vector _topics); + + void setToken(const QString &_token); + + QByteArray toJson() const; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Message.hpp b/src/providers/twitch/pubsubmessages/Message.hpp new file mode 100644 index 000000000..2ce8a345d --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Message.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "common/QLogging.hpp" + +#include +#include +#include + +#include + +namespace chatterino { + +struct PubSubMessageMessage { + QString nonce; + QString topic; + + QJsonObject messageObject; + + PubSubMessageMessage(QString _nonce, const QJsonObject &data) + : nonce(std::move(_nonce)) + , topic(data.value("topic").toString()) + { + auto messagePayload = data.value("message").toString().toUtf8(); + + auto messageDoc = QJsonDocument::fromJson(messagePayload); + + if (messageDoc.isNull()) + { + qCWarning(chatterinoPubSub) << "PubSub message (type MESSAGE) " + "missing inner message payload"; + return; + } + + if (!messageDoc.isObject()) + { + qCWarning(chatterinoPubSub) + << "PubSub message (type MESSAGE) inner message payload is not " + "an object"; + return; + } + + this->messageObject = messageDoc.object(); + } + + template + boost::optional toInner() const; +}; + +template +boost::optional PubSubMessageMessage::toInner() const +{ + if (this->messageObject.empty()) + { + return boost::none; + } + + return InnerClass{this->messageObject}; +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Unlisten.cpp b/src/providers/twitch/pubsubmessages/Unlisten.cpp new file mode 100644 index 000000000..3ba6f571b --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Unlisten.cpp @@ -0,0 +1,40 @@ +#include "providers/twitch/pubsubmessages/Unlisten.hpp" + +#include "util/Helpers.hpp" + +#include +#include +#include + +namespace chatterino { + +PubSubUnlistenMessage::PubSubUnlistenMessage(std::vector _topics) + : topics(std::move(_topics)) + , nonce(generateUuid()) +{ +} + +QByteArray PubSubUnlistenMessage::toJson() const +{ + QJsonObject root; + + root["type"] = "UNLISTEN"; + root["nonce"] = this->nonce; + + { + QJsonObject data; + + QJsonArray jsonTopics; + + std::copy(this->topics.begin(), this->topics.end(), + std::back_inserter(jsonTopics)); + + data["topics"] = jsonTopics; + + root["data"] = data; + } + + return QJsonDocument(root).toJson(); +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Unlisten.hpp b/src/providers/twitch/pubsubmessages/Unlisten.hpp new file mode 100644 index 000000000..d8e1d7992 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Unlisten.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include + +namespace chatterino { + +// PubSubUnlistenMessage is an outgoing UNLISTEN message that is sent for the client to unsubscribe from a list of topics +struct PubSubUnlistenMessage { + const std::vector topics; + + const QString nonce; + + PubSubUnlistenMessage(std::vector _topics); + + QByteArray toJson() const; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Whisper.cpp b/src/providers/twitch/pubsubmessages/Whisper.cpp new file mode 100644 index 000000000..d0b59d0c6 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Whisper.cpp @@ -0,0 +1,38 @@ +#include "providers/twitch/pubsubmessages/Whisper.hpp" + +namespace chatterino { + +PubSubWhisperMessage::PubSubWhisperMessage(const QJsonObject &root) + : typeString(root.value("type").toString()) +{ + auto oType = magic_enum::enum_cast(this->typeString.toStdString()); + if (oType.has_value()) + { + this->type = oType.value(); + } + + // Parse information from data_object + auto data = root.value("data_object").toObject(); + + this->messageID = data.value("message_id").toString(); + this->id = data.value("id").toInt(); + this->threadID = data.value("thread_id").toString(); + this->body = data.value("body").toString(); + auto fromID = data.value("from_id"); + if (fromID.isString()) + { + this->fromUserID = fromID.toString(); + } + else + { + this->fromUserID = QString::number(data.value("from_id").toInt()); + } + + auto tags = data.value("tags").toObject(); + + this->fromUserLogin = tags.value("login").toString(); + this->fromUserDisplayName = tags.value("display_name").toString(); + this->fromUserColor = QColor(tags.value("color").toString()); +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Whisper.hpp b/src/providers/twitch/pubsubmessages/Whisper.hpp new file mode 100644 index 000000000..af29f74a5 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/Whisper.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +#include + +namespace chatterino { + +struct PubSubWhisperMessage { + enum class Type { + WhisperReceived, + WhisperSent, + Thread, + + INVALID, + }; + + QString typeString; + Type type = Type::INVALID; + + QString messageID; + int id; + QString threadID; + QString body; + QString fromUserID; + QString fromUserLogin; + QString fromUserDisplayName; + QColor fromUserColor; + + PubSubWhisperMessage(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::PubSubWhisperMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubWhisperMessage::Type::WhisperReceived: + return "whisper_received"; + + case chatterino::PubSubWhisperMessage::Type::WhisperSent: + return "whisper_sent"; + + case chatterino::PubSubWhisperMessage::Type::Thread: + return "thread"; + default: + return default_tag; + } +} diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index 78442a9f6..ee4f2c9f0 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -28,7 +28,8 @@ #ifndef NDEBUG # include -# include "providers/twitch/PubsubClient.hpp" +# include "providers/twitch/PubSubManager.hpp" +# include "providers/twitch/PubSubMessages.hpp" # include "util/SampleCheerMessages.hpp" # include "util/SampleLinks.hpp" #endif @@ -56,10 +57,10 @@ Window::Window(WindowType type) this->addMenuBar(); #endif - this->signalHolder_.managedConnect( - getApp()->accounts->twitch.currentUserChanged, [this] { + this->bSignals_.emplace_back( + getApp()->accounts->twitch.currentUserChanged.connect([this] { this->onAccountSelected(); - }); + })); this->onAccountSelected(); if (type == WindowType::Main) @@ -284,17 +285,24 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) static bool alt = true; if (alt) { - doc.Parse(channelRewardMessage); + auto oMessage = parsePubSubBaseMessage(channelRewardMessage); + auto oInnerMessage = + oMessage->toInner() + ->toInner(); + app->twitch->addFakeMessage(channelRewardIRCMessage); app->twitch->pubsub->signals_.pointReward.redeemed.invoke( - doc["data"]["message"]["data"]["redemption"]); + oInnerMessage->data.value("redemption").toObject()); alt = !alt; } else { - doc.Parse(channelRewardMessage2); + auto oMessage = parsePubSubBaseMessage(channelRewardMessage2); + auto oInnerMessage = + oMessage->toInner() + ->toInner(); app->twitch->pubsub->signals_.pointReward.redeemed.invoke( - doc["data"]["message"]["data"]["redemption"]); + oInnerMessage->data.value("redemption").toObject()); alt = !alt; } return ""; diff --git a/src/widgets/Window.hpp b/src/widgets/Window.hpp index 7eed35700..245f0d887 100644 --- a/src/widgets/Window.hpp +++ b/src/widgets/Window.hpp @@ -2,6 +2,7 @@ #include "widgets/BaseWindow.hpp" +#include #include #include #include @@ -48,6 +49,7 @@ private: std::shared_ptr updateDialogHandle_; pajlada::Signals::SignalHolder signalHolder_; + std::vector bSignals_; friend class Notebook; }; diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 78c2c4f83..cfb9c84e3 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -167,6 +167,9 @@ AboutPage::AboutPage() addLicense(form.getElement(), "lrucache", "https://github.com/lamerman/cpp-lru-cache", ":/licenses/lrucache.txt"); + addLicense(form.getElement(), "magic_enum", + "https://github.com/Neargye/magic_enum", + ":/licenses/magic_enum.txt"); } // Attributions diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 2a0267a2b..b67b8c569 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -103,10 +103,10 @@ Split::Split(QWidget *parent) this->input_->ui_.textEdit->installEventFilter(parent); // update placeholder text on Twitch account change and channel change - this->signalHolder_.managedConnect( - getApp()->accounts->twitch.currentUserChanged, [this] { + this->bSignals_.emplace_back( + getApp()->accounts->twitch.currentUserChanged.connect([this] { this->updateInputPlaceholder(); - }); + })); this->signalHolder_.managedConnect(channelChanged, [this] { this->updateInputPlaceholder(); }); diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index d0c0b6bab..3f49d26fa 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace chatterino { @@ -151,6 +152,7 @@ private: pajlada::Signals::Connection indirectChannelChangedConnection_; pajlada::Signals::SignalHolder signalHolder_; + std::vector bSignals_; public slots: void addSibling(); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index c468a5c95..f62aaf33e 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -204,10 +204,10 @@ SplitHeader::SplitHeader(Split *_split) this->handleChannelChanged(); }); - this->managedConnections_.managedConnect( - getApp()->accounts->twitch.currentUserChanged, [this] { + this->bSignals_.emplace_back( + getApp()->accounts->twitch.currentUserChanged.connect([this] { this->updateModerationModeIcon(); - }); + })); auto _ = [this](const auto &, const auto &) { this->updateChannelText(); diff --git a/src/widgets/splits/SplitHeader.hpp b/src/widgets/splits/SplitHeader.hpp index f3cc62212..f242f0ead 100644 --- a/src/widgets/splits/SplitHeader.hpp +++ b/src/widgets/splits/SplitHeader.hpp @@ -2,15 +2,16 @@ #include "widgets/BaseWidget.hpp" +#include #include #include -#include +#include #include #include #include -#include -#include +#include +#include namespace chatterino { @@ -85,6 +86,7 @@ private: pajlada::Signals::NoArgSignal modeUpdateRequested_; pajlada::Signals::SignalHolder managedConnections_; pajlada::Signals::SignalHolder channelConnections_; + std::vector bSignals_; public slots: void reloadChannelEmotes(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 181f6c9ad..9b1641d25 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/Hotkeys.cpp ${CMAKE_CURRENT_LIST_DIR}/src/UtilTwitch.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IrcHelpers.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/TwitchPubSubClient.cpp # Add your new file above this line! ) diff --git a/tests/src/TwitchPubSubClient.cpp b/tests/src/TwitchPubSubClient.cpp new file mode 100644 index 000000000..f2b81e9a0 --- /dev/null +++ b/tests/src/TwitchPubSubClient.cpp @@ -0,0 +1,439 @@ +#include "providers/twitch/PubSubManager.hpp" + +#include "providers/twitch/PubSubActions.hpp" + +#include + +using namespace chatterino; +using namespace std::chrono_literals; + +/** + * Server behaves normally and responds to pings (COMPLETE) + * Server doesn't respond to pings, client should disconnect (COMPLETE) + * Server randomly disconnects us, we should reconnect (COMPLETE) + * Client listens to more than 50 topics, so it opens 2 connections (COMPLETE) + * Server sends RECONNECT message to us, we should reconnect (INCOMPLETE, leaving for now since if we just ignore it and Twitch disconnects us we should already handle it properly) + * Listen that required authentication, but authentication is missing (COMPLETE) + * Listen that required authentication, but authentication is wrong (COMPLETE) + * Incoming Whisper message (COMPLETE) + * Incoming AutoMod message + * Incoming ChannelPoints message + * Incoming ChatModeratorAction message (COMPLETE) + **/ + +#define RUN_PUBSUB_TESTS + +#ifdef RUN_PUBSUB_TESTS + +TEST(TwitchPubSubClient, ServerRespondsToPings) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 0); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 0); + + pubSub->listenToTopic("test"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + ASSERT_EQ(pubSub->diag.listenResponses, 1); + + std::this_thread::sleep_for(2s); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 4); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 4); + ASSERT_EQ(pubSub->diag.listenResponses, 1); +} + +TEST(TwitchPubSubClient, ServerDoesntRespondToPings) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050/dont-respond-to-ping"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + pubSub->listenToTopic("test"); + + std::this_thread::sleep_for(750ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 1); + + std::this_thread::sleep_for(500ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 2); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); +} + +TEST(TwitchPubSubClient, DisconnectedAfter1s) +{ + auto pingInterval = std::chrono::seconds(10); + const QString host("wss://127.0.0.1:9050/disconnect-client-after-1s"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 0); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 0); + ASSERT_EQ(pubSub->diag.listenResponses, 0); + + pubSub->listenToTopic("test"); + + std::this_thread::sleep_for(500ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); // Listen RESPONSE & Pong + ASSERT_EQ(pubSub->diag.listenResponses, 1); + + std::this_thread::sleep_for(350ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + + std::this_thread::sleep_for(600ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.listenResponses, 2); + ASSERT_EQ(pubSub->diag.messagesReceived, 4); // new listen & new pong + + pubSub->stop(); +} + +TEST(TwitchPubSubClient, ExceedTopicLimit) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 0); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 0); + + for (auto i = 0; i < PubSubClient::MAX_LISTENS; ++i) + { + pubSub->listenToTopic(QString("test-1.%1").arg(i)); + } + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + + for (auto i = 0; i < PubSubClient::MAX_LISTENS; ++i) + { + pubSub->listenToTopic(QString("test-2.%1").arg(i)); + } + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 2); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, ExceedTopicLimitSingleStep) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 0); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 0); + + for (auto i = 0; i < PubSubClient::MAX_LISTENS * 2; ++i) + { + pubSub->listenToTopic("test"); + } + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 2); + ASSERT_EQ(pubSub->diag.connectionsClosed, 2); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, ReceivedWhisper) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050/receive-whisper"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + boost::optional oReceivedWhisper; + + pubSub->signals_.whisper.received.connect( + [&oReceivedWhisper](const auto &whisperMessage) { + oReceivedWhisper = whisperMessage; + }); + + pubSub->listenToTopic("whispers.123456"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 3); + ASSERT_EQ(pubSub->diag.listenResponses, 1); + + ASSERT_TRUE(oReceivedWhisper); + + auto receivedWhisper = *oReceivedWhisper; + + ASSERT_EQ(receivedWhisper.body, QString("me Kappa")); + ASSERT_EQ(receivedWhisper.fromUserLogin, QString("pajbot")); + ASSERT_EQ(receivedWhisper.fromUserID, QString("82008718")); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, ModeratorActionsUserBanned) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050/moderator-actions-user-banned"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("token", "123456"); + pubSub->start(); + + boost::optional oReceivedAction; + + pubSub->signals_.moderation.userBanned.connect( + [&oReceivedAction](const auto &action) { + oReceivedAction = action; + }); + + ASSERT_EQ(pubSub->diag.listenResponses, 0); + + pubSub->listenToTopic("chat_moderator_actions.123456.123456"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 3); + ASSERT_EQ(pubSub->diag.listenResponses, 1); + + ASSERT_TRUE(oReceivedAction); + + auto receivedAction = *oReceivedAction; + + ActionUser expectedTarget{"140114344", "1xelerate", "", QColor()}; + ActionUser expectedSource{"117691339", "mm2pl", "", QColor()}; + + ASSERT_EQ(receivedAction.reason, QString()); + ASSERT_EQ(receivedAction.duration, 0); + ASSERT_EQ(receivedAction.target, expectedTarget); + ASSERT_EQ(receivedAction.source, expectedSource); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, MissingToken) +{ + auto pingInterval = std::chrono::seconds(1); + // The token that's required is "xD" + const QString host("wss://127.0.0.1:9050/authentication-required"); + + auto *pubSub = new PubSub(host, pingInterval); + // pubSub->setAccountData("", "123456"); + pubSub->start(); + + pubSub->listenToTopic("chat_moderator_actions.123456.123456"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + ASSERT_EQ(pubSub->diag.listenResponses, 0); + ASSERT_EQ(pubSub->diag.failedListenResponses, 1); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, WrongToken) +{ + auto pingInterval = std::chrono::seconds(1); + // The token that's required is "xD" + const QString host("wss://127.0.0.1:9050/authentication-required"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("wrongtoken", "123456"); + pubSub->start(); + + pubSub->listenToTopic("chat_moderator_actions.123456.123456"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + ASSERT_EQ(pubSub->diag.listenResponses, 0); + ASSERT_EQ(pubSub->diag.failedListenResponses, 1); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, CorrectToken) +{ + auto pingInterval = std::chrono::seconds(1); + // The token that's required is "xD" + const QString host("wss://127.0.0.1:9050/authentication-required"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("xD", "123456"); + pubSub->start(); + + pubSub->listenToTopic("chat_moderator_actions.123456.123456"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 2); + ASSERT_EQ(pubSub->diag.listenResponses, 1); + ASSERT_EQ(pubSub->diag.failedListenResponses, 0); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +TEST(TwitchPubSubClient, AutoModMessageHeld) +{ + auto pingInterval = std::chrono::seconds(1); + const QString host("wss://127.0.0.1:9050/automod-held"); + + auto *pubSub = new PubSub(host, pingInterval); + pubSub->setAccountData("xD", "123456"); + pubSub->start(); + + boost::optional oReceived; + boost::optional oChannelID; + + pubSub->signals_.moderation.autoModMessageCaught.connect( + [&](const auto &msg, const QString &channelID) { + oReceived = msg; + oChannelID = channelID; + }); + + pubSub->listenToTopic("automod-queue.117166826.117166826"); + + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 0); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); + ASSERT_EQ(pubSub->diag.messagesReceived, 3); + ASSERT_EQ(pubSub->diag.listenResponses, 1); + ASSERT_EQ(pubSub->diag.failedListenResponses, 0); + + ASSERT_TRUE(oReceived); + ASSERT_TRUE(oChannelID); + + auto received = *oReceived; + auto channelID = *oChannelID; + + ASSERT_EQ(channelID, "117166826"); + ASSERT_EQ(received.messageText, "kurwa"); + + pubSub->stop(); + + ASSERT_EQ(pubSub->diag.connectionsOpened, 1); + ASSERT_EQ(pubSub->diag.connectionsClosed, 1); + ASSERT_EQ(pubSub->diag.connectionsFailed, 0); +} + +#endif diff --git a/tests/src/main.cpp b/tests/src/main.cpp index 779c86417..38db15757 100644 --- a/tests/src/main.cpp +++ b/tests/src/main.cpp @@ -22,10 +22,13 @@ using namespace chatterino; +#define SUPPORT_QT_NETWORK_TESTS + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); +#ifdef SUPPORT_QT_NETWORK_TESTS QApplication app(argc, argv); chatterino::NetworkManager::init(); @@ -39,4 +42,7 @@ int main(int argc, char **argv) }); return app.exec(); +#else + return RUN_ALL_TESTS(); +#endif }