diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c03940b5..9a9bf685d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: env: - TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.4 + TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.5 concurrency: group: test-${{ github.ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a6969cf86..18db4b434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905, #4131) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) -- Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) +- Major: Added support for emotes, badges, and live emote updates from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062, #4090) - Major: Added support for Right-to-Left Languages (#3958, #4139) - Minor: Added setting to keep more message history in splits. (#3811) - Minor: Added setting to keep more message history in usercards. (#3811) diff --git a/src/Application.cpp b/src/Application.cpp index a17c53b48..190cfb67a 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -20,6 +20,7 @@ #include "providers/irc/Irc2.hpp" #include "providers/seventv/SeventvBadges.hpp" #include "providers/seventv/SeventvEmotes.hpp" +#include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" @@ -149,6 +150,8 @@ void Application::initialize(Settings &settings, Paths &paths) this->initNm(paths); } this->initPubSub(); + + this->initSeventvEventAPI(); } int Application::run(QApplication &qtApp) @@ -563,6 +566,53 @@ void Application::initPubSub() RequestModerationActions(); } +void Application::initSeventvEventAPI() +{ + if (!this->twitch->seventvEventAPI) + { + qCDebug(chatterinoSeventvEventAPI) + << "Skipping initialization as the EventAPI is disabled"; + return; + } + + this->twitch->seventvEventAPI->signals_.emoteAdded.connect( + [&](const auto &data) { + postToThread([this, data] { + this->twitch->forEachSeventvEmoteSet( + data.emoteSetID, [data](TwitchChannel &chan) { + chan.addSeventvEmote(data); + }); + }); + }); + this->twitch->seventvEventAPI->signals_.emoteUpdated.connect( + [&](const auto &data) { + postToThread([this, data] { + this->twitch->forEachSeventvEmoteSet( + data.emoteSetID, [data](TwitchChannel &chan) { + chan.updateSeventvEmote(data); + }); + }); + }); + this->twitch->seventvEventAPI->signals_.emoteRemoved.connect( + [&](const auto &data) { + postToThread([this, data] { + this->twitch->forEachSeventvEmoteSet( + data.emoteSetID, [data](TwitchChannel &chan) { + chan.removeSeventvEmote(data); + }); + }); + }); + this->twitch->seventvEventAPI->signals_.userUpdated.connect( + [&](const auto &data) { + this->twitch->forEachSeventvUser(data.userID, + [data](TwitchChannel &chan) { + chan.updateSeventvUser(data); + }); + }); + + this->twitch->seventvEventAPI->start(); +} + Application *getApp() { assert(Application::instance != nullptr); diff --git a/src/Application.hpp b/src/Application.hpp index 86af27753..7aaa27513 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -145,6 +145,7 @@ public: private: void addSingleton(Singleton *singleton); void initPubSub(); + void initSeventvEventAPI(); void initNm(Paths &paths); template end(); + if (!emoteNameHint.isEmpty()) + { + it = this->find(EmoteName{emoteNameHint}); + } + + if (it == this->end() || it->second->id.string != emoteID) + { + it = std::find_if(this->begin(), this->end(), + [emoteID](const auto entry) { + return entry.second->id.string == emoteID; + }); + } + return it; +} + } // namespace chatterino diff --git a/src/messages/Emote.hpp b/src/messages/Emote.hpp index 380f57dcb..a736a9805 100644 --- a/src/messages/Emote.hpp +++ b/src/messages/Emote.hpp @@ -1,8 +1,10 @@ #pragma once +#include "common/Atomic.hpp" #include "messages/Image.hpp" #include "messages/ImageSet.hpp" +#include #include #include #include @@ -15,6 +17,13 @@ struct Emote { Tooltip tooltip; Url homePage; bool zeroWidth; + EmoteId id; + EmoteAuthor author; + /** + * If this emote is aliased, this contains + * the original (base) name of the emote. + */ + boost::optional baseName; // FOURTF: no solution yet, to be refactored later const QString &getCopyString() const @@ -30,6 +39,20 @@ using EmotePtr = std::shared_ptr; class EmoteMap : public std::unordered_map { +public: + /** + * Finds an emote by it's id with a hint to it's name. + * + * 1. Searches by name for the emote, checking if the ids match (fast-path). + * 2. Searches through the map for an emote with the `emoteID` (slow-path). + * + * @param emoteNameHint A hint to the name of the searched emote, + * may be empty. + * @param emoteID The emote id to search for. + * @return An iterator to the found emote (possibly this->end()). + */ + EmoteMap::const_iterator findEmote(const QString &emoteNameHint, + const QString &emoteID) const; }; using EmoteIdMap = std::unordered_map; using WeakEmoteMap = std::unordered_map>; diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 8e64c663a..6a36cf877 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -45,6 +45,9 @@ enum class MessageFlag : int64_t { ElevatedMessage = (1LL << 25), ParticipatedThread = (1LL << 26), CheerMessage = (1LL << 27), + LiveUpdatesAdd = (1LL << 28), + LiveUpdatesRemove = (1LL << 29), + LiveUpdatesUpdate = (1LL << 30), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 4eab00cf7..735d81aa2 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -23,6 +23,45 @@ QRegularExpression IRC_COLOR_PARSE_REGEX( "(\u0003(\\d{1,2})?(,(\\d{1,2}))?|\u000f)", QRegularExpression::UseUnicodePropertiesOption); +QString formatUpdatedEmoteList(const QString &platform, + const std::vector &emoteNames, + bool isAdd, bool isFirstWord) +{ + QString text = ""; + if (isAdd) + { + text += isFirstWord ? "Added" : "added"; + } + else + { + text += isFirstWord ? "Removed" : "removed"; + } + + if (emoteNames.size() == 1) + { + text += QString(" %1 emote ").arg(platform); + } + else + { + text += QString(" %1 %2 emotes ").arg(emoteNames.size()).arg(platform); + } + + auto i = 0; + for (const auto &emoteName : emoteNames) + { + i++; + if (i > 1) + { + text += i == emoteNames.size() ? " and " : ", "; + } + text += emoteName; + } + + text += "."; + + return text; +} + } // namespace namespace chatterino { @@ -473,6 +512,133 @@ MessageBuilder::MessageBuilder(const AutomodUserAction &action) MessageColor::System); } +MessageBuilder::MessageBuilder(LiveUpdatesAddEmoteMessageTag /*unused*/, + const QString &platform, const QString &actor, + const std::vector &emoteNames) + : MessageBuilder() +{ + auto text = + formatUpdatedEmoteList(platform, emoteNames, true, actor.isEmpty()); + + this->emplace(); + if (!actor.isEmpty()) + { + this->emplace(actor, MessageElementFlag::Username, + MessageColor::System) + ->setLink({Link::UserInfo, actor}); + } + this->emplace(text, MessageElementFlag::Text, + MessageColor::System); + + QString finalText; + if (actor.isEmpty()) + { + finalText = text; + } + else + { + finalText = QString("%1 %2").arg(actor, text); + } + + this->message().loginName = actor; + this->message().messageText = finalText; + this->message().searchText = finalText; + + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::LiveUpdatesAdd); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); +} + +MessageBuilder::MessageBuilder(LiveUpdatesRemoveEmoteMessageTag /*unused*/, + const QString &platform, const QString &actor, + const std::vector &emoteNames) + : MessageBuilder() +{ + auto text = + formatUpdatedEmoteList(platform, emoteNames, false, actor.isEmpty()); + + this->emplace(); + if (!actor.isEmpty()) + { + this->emplace(actor, MessageElementFlag::Username, + MessageColor::System) + ->setLink({Link::UserInfo, actor}); + } + this->emplace(text, MessageElementFlag::Text, + MessageColor::System); + + QString finalText; + if (actor.isEmpty()) + { + finalText = text; + } + else + { + finalText = QString("%1 %2").arg(actor, text); + } + + this->message().loginName = actor; + this->message().messageText = finalText; + this->message().searchText = finalText; + + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::LiveUpdatesRemove); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); +} + +MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteMessageTag /*unused*/, + const QString &platform, const QString &actor, + const QString &emoteName, + const QString &oldEmoteName) + : MessageBuilder() +{ + auto text = QString("renamed %1 emote %2 to %3.") + .arg(platform, oldEmoteName, emoteName); + + this->emplace(); + this->emplace(actor, MessageElementFlag::Username, + MessageColor::System) + ->setLink({Link::UserInfo, actor}); + this->emplace(text, MessageElementFlag::Text, + MessageColor::System); + + auto finalText = QString("%1 %2").arg(actor, text); + + this->message().loginName = actor; + this->message().messageText = finalText; + this->message().searchText = finalText; + + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::LiveUpdatesUpdate); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); +} + +MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/, + const QString &platform, const QString &actor, + const QString &emoteSetName) + : MessageBuilder() +{ + auto text = QString("switched the active %1 Emote Set to \"%2\".") + .arg(platform, emoteSetName); + + this->emplace(); + this->emplace(actor, MessageElementFlag::Username, + MessageColor::System) + ->setLink({Link::UserInfo, actor}); + this->emplace(text, MessageElementFlag::Text, + MessageColor::System); + + auto finalText = QString("%1 %2").arg(actor, text); + + this->message().loginName = actor; + this->message().messageText = finalText; + this->message().searchText = finalText; + + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::LiveUpdatesUpdate); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); +} + Message *MessageBuilder::operator->() { return this->message_.get(); diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 7d725b13b..88d57eb6a 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -19,8 +19,20 @@ struct SystemMessageTag { }; struct TimeoutMessageTag { }; +struct LiveUpdatesUpdateEmoteMessageTag { +}; +struct LiveUpdatesRemoveEmoteMessageTag { +}; +struct LiveUpdatesAddEmoteMessageTag { +}; +struct LiveUpdatesUpdateEmoteSetMessageTag { +}; const SystemMessageTag systemMessage{}; const TimeoutMessageTag timeoutMessage{}; +const LiveUpdatesUpdateEmoteMessageTag liveUpdatesUpdateEmoteMessage{}; +const LiveUpdatesRemoveEmoteMessageTag liveUpdatesRemoveEmoteMessage{}; +const LiveUpdatesAddEmoteMessageTag liveUpdatesAddEmoteMessage{}; +const LiveUpdatesUpdateEmoteSetMessageTag liveUpdatesUpdateEmoteSetMessage{}; MessagePtr makeSystemMessage(const QString &text); MessagePtr makeSystemMessage(const QString &text, const QTime &time); @@ -53,6 +65,19 @@ public: MessageBuilder(const BanAction &action, uint32_t count = 1); MessageBuilder(const UnbanAction &action); MessageBuilder(const AutomodUserAction &action); + + MessageBuilder(LiveUpdatesAddEmoteMessageTag, const QString &platform, + const QString &actor, + const std::vector &emoteNames); + MessageBuilder(LiveUpdatesRemoveEmoteMessageTag, const QString &platform, + const QString &actor, + const std::vector &emoteNames); + MessageBuilder(LiveUpdatesUpdateEmoteMessageTag, const QString &platform, + const QString &actor, const QString &emoteName, + const QString &oldEmoteName); + MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag, const QString &platform, + const QString &actor, const QString &emoteSetName); + virtual ~MessageBuilder() = default; Message *operator->(); diff --git a/src/providers/liveupdates/BasicPubSubClient.hpp b/src/providers/liveupdates/BasicPubSubClient.hpp index d23ca6444..70ee62523 100644 --- a/src/providers/liveupdates/BasicPubSubClient.hpp +++ b/src/providers/liveupdates/BasicPubSubClient.hpp @@ -121,27 +121,6 @@ protected: return true; } - bool isStarted() const - { - return this->started_.load(std::memory_order_acquire); - } - - liveupdates::WebsocketClient &websocketClient_; - -private: - void start() - { - assert(!this->isStarted()); - this->started_.store(true, std::memory_order_release); - this->onConnectionEstablished(); - } - - void stop() - { - assert(this->isStarted()); - this->started_.store(false, std::memory_order_release); - } - void close(const std::string &reason, websocketpp::close::status::value code = websocketpp::close::status::normal) @@ -165,6 +144,27 @@ private: } } + bool isStarted() const + { + return this->started_.load(std::memory_order_acquire); + } + + liveupdates::WebsocketClient &websocketClient_; + +private: + void start() + { + assert(!this->isStarted()); + this->started_.store(true, std::memory_order_release); + this->onConnectionEstablished(); + } + + void stop() + { + assert(this->isStarted()); + this->started_.store(false, std::memory_order_release); + } + liveupdates::WebsocketHandle handle_; std::unordered_set subscriptions_; diff --git a/src/providers/liveupdates/BasicPubSubManager.hpp b/src/providers/liveupdates/BasicPubSubManager.hpp index ced55070d..664e8c42f 100644 --- a/src/providers/liveupdates/BasicPubSubManager.hpp +++ b/src/providers/liveupdates/BasicPubSubManager.hpp @@ -1,6 +1,7 @@ #pragma once #include "common/QLogging.hpp" +#include "common/Version.hpp" #include "providers/liveupdates/BasicPubSubClient.hpp" #include "providers/liveupdates/BasicPubSubWebsocket.hpp" #include "providers/twitch/PubSubHelpers.hpp" @@ -85,6 +86,8 @@ public: this->websocketClient_.set_fail_handler([this](auto hdl) { this->onConnectionFail(hdl); }); + this->websocketClient_.set_user_agent("Chatterino/" CHATTERINO_VERSION + " (" CHATTERINO_GIT_HASH ")"); } virtual ~BasicPubSubManager() = default; diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index 124cfb4db..e853a8ad5 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -32,9 +32,9 @@ using namespace chatterino; const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes."); const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1"); -// TODO(nerix): add links to documentation (7tv.io) const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1"); const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global"); +const QString API_URL_EMOTE_SET("https://7tv.io/v3/emote-sets/%1"); struct CreateEmoteResult { Emote emote; @@ -160,17 +160,20 @@ CreateEmoteResult createEmote(const QJsonObject &activeEmote, auto emoteName = EmoteName{activeEmote["name"].toString()}; auto author = EmoteAuthor{emoteData["owner"].toObject()["display_name"].toString()}; - auto baseEmoteName = emoteData["name"].toString(); + auto baseEmoteName = EmoteName{emoteData["name"].toString()}; bool zeroWidth = isZeroWidthActive(activeEmote); - bool aliasedName = emoteName.string != baseEmoteName; + bool aliasedName = emoteName != baseEmoteName; auto tooltip = - aliasedName ? createAliasedTooltip(emoteName.string, baseEmoteName, - author.string, isGlobal) - : createTooltip(emoteName.string, author.string, isGlobal); + aliasedName + ? createAliasedTooltip(emoteName.string, baseEmoteName.string, + author.string, isGlobal) + : createTooltip(emoteName.string, author.string, isGlobal); auto imageSet = makeImageSet(emoteData); - auto emote = Emote({emoteName, imageSet, tooltip, - Url{EMOTE_LINK_FORMAT.arg(emoteId.string)}, zeroWidth}); + auto emote = + Emote({emoteName, imageSet, tooltip, + Url{EMOTE_LINK_FORMAT.arg(emoteId.string)}, zeroWidth, emoteId, + author, boost::make_optional(aliasedName, baseEmoteName)}); return {emote, emoteId, emoteName, !emote.images.getImage1()->isEmpty()}; } @@ -217,6 +220,24 @@ EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal) return emotes; } +EmotePtr createUpdatedEmote(const EmotePtr &oldEmote, + const SeventvEventAPIEmoteUpdateDispatch &dispatch) +{ + bool toNonAliased = oldEmote->baseName.has_value() && + dispatch.emoteName == oldEmote->baseName->string; + + auto baseName = oldEmote->baseName.get_value_or(oldEmote->name); + auto emote = std::make_shared(Emote( + {EmoteName{dispatch.emoteName}, oldEmote->images, + toNonAliased + ? createTooltip(dispatch.emoteName, oldEmote->author.string, false) + : createAliasedTooltip(dispatch.emoteName, baseName.string, + oldEmote->author.string, false), + oldEmote->homePage, oldEmote->zeroWidth, oldEmote->id, + oldEmote->author, boost::make_optional(!toNonAliased, baseName)})); + return emote; +} + } // namespace namespace chatterino { @@ -273,10 +294,9 @@ void SeventvEmotes::loadGlobalEmotes() .execute(); } -void SeventvEmotes::loadChannelEmotes(const std::weak_ptr &channel, - const QString &channelId, - std::function callback, - bool manualRefresh) +void SeventvEmotes::loadChannelEmotes( + const std::weak_ptr &channel, const QString &channelId, + std::function callback, bool manualRefresh) { qCDebug(chatterinoSeventv) << "Reloading 7TV Channel Emotes" << channelId << manualRefresh; @@ -298,7 +318,21 @@ void SeventvEmotes::loadChannelEmotes(const std::weak_ptr &channel, if (hasEmotes) { - callback(std::move(emoteMap)); + auto user = json["user"].toObject(); + + size_t connectionIdx = 0; + for (const auto &conn : user["connections"].toArray()) + { + if (conn.toObject()["platform"].toString() == "TWITCH") + { + break; + } + connectionIdx++; + } + + callback(std::move(emoteMap), + {user["id"].toString(), emoteSet["id"].toString(), + connectionIdx}); } auto shared = channel.lock(); @@ -362,4 +396,110 @@ void SeventvEmotes::loadChannelEmotes(const std::weak_ptr &channel, .execute(); } +boost::optional SeventvEmotes::addEmote( + Atomic> &map, + const SeventvEventAPIEmoteAddDispatch &dispatch) +{ + // Check for visibility first, so we don't copy the map. + auto emoteData = dispatch.emoteJson["data"].toObject(); + if (emoteData.empty() || !checkEmoteVisibility(emoteData)) + { + return boost::none; + } + + // This copies the map. + EmoteMap updatedMap = *map.get(); + auto result = createEmote(dispatch.emoteJson, emoteData, false); + if (!result.hasImages) + { + // Incoming emote didn't contain any images, abort + qCDebug(chatterinoSeventv) + << "Emote without images:" << dispatch.emoteJson; + return boost::none; + } + auto emote = std::make_shared(std::move(result.emote)); + updatedMap[result.name] = emote; + map.set(std::make_shared(std::move(updatedMap))); + + return emote; +} + +boost::optional SeventvEmotes::updateEmote( + Atomic> &map, + const SeventvEventAPIEmoteUpdateDispatch &dispatch) +{ + auto oldMap = map.get(); + auto oldEmote = oldMap->findEmote(dispatch.emoteName, dispatch.emoteID); + if (oldEmote == oldMap->end()) + { + return boost::none; + } + + // This copies the map. + EmoteMap updatedMap = *map.get(); + updatedMap.erase(oldEmote->second->name); + + auto emote = createUpdatedEmote(oldEmote->second, dispatch); + updatedMap[emote->name] = emote; + map.set(std::make_shared(std::move(updatedMap))); + + return emote; +} + +boost::optional SeventvEmotes::removeEmote( + Atomic> &map, + const SeventvEventAPIEmoteRemoveDispatch &dispatch) +{ + // This copies the map. + EmoteMap updatedMap = *map.get(); + auto it = updatedMap.findEmote(dispatch.emoteName, dispatch.emoteID); + if (it == updatedMap.end()) + { + // We already copied the map at this point and are now discarding the copy. + // This is fine, because this case should be really rare. + return boost::none; + } + auto emote = it->second; + updatedMap.erase(it); + map.set(std::make_shared(std::move(updatedMap))); + + return emote; +} + +void SeventvEmotes::getEmoteSet( + const QString &emoteSetId, + std::function successCallback, + std::function errorCallback) +{ + qCDebug(chatterinoSeventv) << "Loading 7TV Emote Set" << emoteSetId; + + NetworkRequest(API_URL_EMOTE_SET.arg(emoteSetId), NetworkRequestType::Get) + .timeout(20000) + .onSuccess([callback = std::move(successCallback), + emoteSetId](const NetworkResult &result) -> Outcome { + auto json = result.parseJson(); + auto parsedEmotes = json["emotes"].toArray(); + + auto emoteMap = parseEmotes(parsedEmotes, false); + + qCDebug(chatterinoSeventv) << "Loaded" << emoteMap.size() + << "7TV Emotes from" << emoteSetId; + + callback(std::move(emoteMap), json["name"].toString()); + return Success; + }) + .onError([emoteSetId, callback = std::move(errorCallback)]( + const NetworkResult &result) { + if (result.status() == NetworkResult::timedoutStatus) + { + callback("timed out"); + } + else + { + callback(QString("status: %1").arg(result.status())); + } + }) + .execute(); +} + } // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.hpp b/src/providers/seventv/SeventvEmotes.hpp index 1569eae12..ebcb664f7 100644 --- a/src/providers/seventv/SeventvEmotes.hpp +++ b/src/providers/seventv/SeventvEmotes.hpp @@ -3,6 +3,7 @@ #include "boost/optional.hpp" #include "common/Aliases.hpp" #include "common/Atomic.hpp" +#include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp" #include "providers/twitch/TwitchChannel.hpp" #include @@ -56,15 +57,60 @@ class EmoteMap; class SeventvEmotes final { public: + struct ChannelInfo { + QString userID; + QString emoteSetID; + size_t twitchConnectionIndex; + }; + SeventvEmotes(); std::shared_ptr globalEmotes() const; boost::optional globalEmote(const EmoteName &name) const; void loadGlobalEmotes(); - static void loadChannelEmotes(const std::weak_ptr &channel, - const QString &channelId, - std::function callback, - bool manualRefresh); + static void loadChannelEmotes( + const std::weak_ptr &channel, const QString &channelId, + std::function callback, + bool manualRefresh); + + /** + * Adds an emote to the `map` if it's valid. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return The added emote if an emote was added. + */ + static boost::optional addEmote( + Atomic> &map, + const SeventvEventAPIEmoteAddDispatch &dispatch); + + /** + * Updates an emote in this `map`. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return The updated emote if any emote was updated. + */ + static boost::optional updateEmote( + Atomic> &map, + const SeventvEventAPIEmoteUpdateDispatch &dispatch); + + /** + * Removes an emote from this `map`. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return The removed emote if any emote was removed. + */ + static boost::optional removeEmote( + Atomic> &map, + const SeventvEventAPIEmoteRemoveDispatch &dispatch); + + /** Fetches an emote-set by its id */ + static void getEmoteSet( + const QString &emoteSetId, + std::function successCallback, + std::function errorCallback); private: Atomic> global_; diff --git a/src/providers/seventv/SeventvEventAPI.cpp b/src/providers/seventv/SeventvEventAPI.cpp new file mode 100644 index 000000000..4193ffbc2 --- /dev/null +++ b/src/providers/seventv/SeventvEventAPI.cpp @@ -0,0 +1,249 @@ +#include "providers/seventv/SeventvEventAPI.hpp" + +#include "providers/seventv/eventapi/SeventvEventAPIClient.hpp" +#include "providers/seventv/eventapi/SeventvEventAPIMessage.hpp" + +#include +#include + +namespace chatterino { + +SeventvEventAPI::SeventvEventAPI( + QString host, std::chrono::milliseconds defaultHeartbeatInterval) + : BasicPubSubManager(std::move(host)) + , heartbeatInterval_(defaultHeartbeatInterval) +{ +} + +void SeventvEventAPI::subscribeUser(const QString &userID, + const QString &emoteSetID) +{ + if (!userID.isEmpty() && this->subscribedUsers_.insert(userID).second) + { + this->subscribe({userID, SeventvEventAPISubscriptionType::UpdateUser}); + } + if (!emoteSetID.isEmpty() && + this->subscribedEmoteSets_.insert(emoteSetID).second) + { + this->subscribe( + {emoteSetID, SeventvEventAPISubscriptionType::UpdateEmoteSet}); + } +} + +void SeventvEventAPI::unsubscribeEmoteSet(const QString &id) +{ + if (this->subscribedEmoteSets_.erase(id) > 0) + { + this->unsubscribe( + {id, SeventvEventAPISubscriptionType::UpdateEmoteSet}); + } +} + +void SeventvEventAPI::unsubscribeUser(const QString &id) +{ + if (this->subscribedUsers_.erase(id) > 0) + { + this->unsubscribe({id, SeventvEventAPISubscriptionType::UpdateUser}); + } +} + +std::shared_ptr> + SeventvEventAPI::createClient(liveupdates::WebsocketClient &client, + websocketpp::connection_hdl hdl) +{ + auto shared = std::make_shared( + client, hdl, this->heartbeatInterval_); + return std::static_pointer_cast< + BasicPubSubClient>(std::move(shared)); +} + +void SeventvEventAPI::onMessage( + websocketpp::connection_hdl hdl, + BasicPubSubManager::WebsocketMessagePtr msg) +{ + const auto &payload = QString::fromStdString(msg->get_payload()); + + auto pMessage = parseSeventvEventAPIBaseMessage(payload); + + if (!pMessage) + { + qCDebug(chatterinoSeventvEventAPI) + << "Unable to parse incoming event-api message: " << payload; + return; + } + auto message = *pMessage; + switch (message.op) + { + case SeventvEventAPIOpcode::Hello: { + if (auto client = this->findClient(hdl)) + { + if (auto *stvClient = + dynamic_cast(client.get())) + { + stvClient->setHeartbeatInterval( + message.data["heartbeat_interval"].toInt()); + } + } + } + break; + case SeventvEventAPIOpcode::Heartbeat: { + if (auto client = this->findClient(hdl)) + { + if (auto *stvClient = + dynamic_cast(client.get())) + { + stvClient->handleHeartbeat(); + } + } + } + break; + case SeventvEventAPIOpcode::Dispatch: { + auto dispatch = message.toInner(); + if (!dispatch) + { + qCDebug(chatterinoSeventvEventAPI) + << "Malformed dispatch" << payload; + return; + } + this->handleDispatch(*dispatch); + } + break; + case SeventvEventAPIOpcode::Reconnect: { + if (auto client = this->findClient(hdl)) + { + if (auto *stvClient = + dynamic_cast(client.get())) + { + stvClient->close("Reconnecting"); + } + } + } + break; + default: { + qCDebug(chatterinoSeventvEventAPI) << "Unhandled op: " << payload; + } + break; + } +} + +void SeventvEventAPI::handleDispatch(const SeventvEventAPIDispatch &dispatch) +{ + switch (dispatch.type) + { + case SeventvEventAPISubscriptionType::UpdateEmoteSet: { + // dispatchBody: { + // pushed: Array<{ key, value }>, + // pulled: Array<{ key, old_value }>, + // updated: Array<{ key, value, old_value }>, + // } + for (const auto pushedRef : dispatch.body["pushed"].toArray()) + { + auto pushed = pushedRef.toObject(); + if (pushed["key"].toString() != "emotes") + { + continue; + } + + SeventvEventAPIEmoteAddDispatch added( + dispatch, pushed["value"].toObject()); + + if (added.validate()) + { + this->signals_.emoteAdded.invoke(added); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid dispatch" << dispatch.body; + } + } + for (const auto updatedRef : dispatch.body["updated"].toArray()) + { + auto updated = updatedRef.toObject(); + if (updated["key"].toString() != "emotes") + { + continue; + } + + SeventvEventAPIEmoteUpdateDispatch update( + dispatch, updated["old_value"].toObject(), + updated["value"].toObject()); + + if (update.validate()) + { + this->signals_.emoteUpdated.invoke(update); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid dispatch" << dispatch.body; + } + } + for (const auto pulledRef : dispatch.body["pulled"].toArray()) + { + auto pulled = pulledRef.toObject(); + if (pulled["key"].toString() != "emotes") + { + continue; + } + + SeventvEventAPIEmoteRemoveDispatch removed( + dispatch, pulled["old_value"].toObject()); + + if (removed.validate()) + { + this->signals_.emoteRemoved.invoke(removed); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid dispatch" << dispatch.body; + } + } + } + break; + case SeventvEventAPISubscriptionType::UpdateUser: { + // dispatchBody: { + // updated: Array<{ key, value: Array<{key, value}> }> + // } + for (const auto updatedRef : dispatch.body["updated"].toArray()) + { + auto updated = updatedRef.toObject(); + if (updated["key"].toString() != "connections") + { + continue; + } + for (const auto valueRef : updated["value"].toArray()) + { + auto value = valueRef.toObject(); + if (value["key"].toString() != "emote_set") + { + continue; + } + + SeventvEventAPIUserConnectionUpdateDispatch update( + dispatch, value, (size_t)updated["index"].toInt()); + + if (update.validate()) + { + this->signals_.userUpdated.invoke(update); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid dispatch" << dispatch.body; + } + } + } + } + break; + default: { + qCDebug(chatterinoSeventvEventAPI) + << "Unknown subscription type:" << (int)dispatch.type + << "body:" << dispatch.body; + } + break; + } +} + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvEventAPI.hpp b/src/providers/seventv/SeventvEventAPI.hpp new file mode 100644 index 000000000..5f6aad3aa --- /dev/null +++ b/src/providers/seventv/SeventvEventAPI.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "providers/liveupdates/BasicPubSubClient.hpp" +#include "providers/liveupdates/BasicPubSubManager.hpp" +#include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp" +#include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp" +#include "util/QStringHash.hpp" + +#include + +namespace chatterino { + +class SeventvEventAPI : public BasicPubSubManager +{ + template + using Signal = + pajlada::Signals::Signal; // type-id is vector> + +public: + SeventvEventAPI(QString host, + std::chrono::milliseconds defaultHeartbeatInterval = + std::chrono::milliseconds(25000)); + + struct { + Signal emoteAdded; + Signal emoteUpdated; + Signal emoteRemoved; + Signal userUpdated; + } signals_; // NOLINT(readability-identifier-naming) + + /** + * Subscribes to a user and emote-set + * if not already subscribed. + * + * @param userID 7TV user-id, may be empty. + * @param emoteSetID 7TV emote-set-id, may be empty. + */ + void subscribeUser(const QString &userID, const QString &emoteSetID); + + /** Unsubscribes from a user by its 7TV user id */ + void unsubscribeUser(const QString &id); + /** Unsubscribes from an emote-set by its id */ + void unsubscribeEmoteSet(const QString &id); + +protected: + std::shared_ptr> + createClient(liveupdates::WebsocketClient &client, + websocketpp::connection_hdl hdl) override; + void onMessage( + websocketpp::connection_hdl hdl, + BasicPubSubManager::WebsocketMessagePtr + msg) override; + +private: + void handleDispatch(const SeventvEventAPIDispatch &dispatch); + + std::unordered_set subscribedEmoteSets_; + std::unordered_set subscribedUsers_; + std::chrono::milliseconds heartbeatInterval_; +}; + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIClient.cpp b/src/providers/seventv/eventapi/SeventvEventAPIClient.cpp new file mode 100644 index 000000000..01c52ac67 --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIClient.cpp @@ -0,0 +1,69 @@ +#include + +#include "providers/seventv/eventapi/SeventvEventAPIClient.hpp" + +#include "providers/twitch/PubSubHelpers.hpp" + +namespace chatterino { + +SeventvEventAPIClient::SeventvEventAPIClient( + liveupdates::WebsocketClient &websocketClient, + liveupdates::WebsocketHandle handle, + std::chrono::milliseconds heartbeatInterval) + : BasicPubSubClient(websocketClient, + std::move(handle)) + , lastHeartbeat_(std::chrono::steady_clock::now()) + , heartbeatInterval_(heartbeatInterval) +{ +} + +void SeventvEventAPIClient::onConnectionEstablished() +{ + this->lastHeartbeat_.store(std::chrono::steady_clock::now(), + std::memory_order_release); + this->checkHeartbeat(); +} + +void SeventvEventAPIClient::setHeartbeatInterval(int intervalMs) +{ + qCDebug(chatterinoSeventvEventAPI) + << "Setting expected heartbeat interval to" << intervalMs << "ms"; + this->heartbeatInterval_ = std::chrono::milliseconds(intervalMs); +} + +void SeventvEventAPIClient::handleHeartbeat() +{ + this->lastHeartbeat_.store(std::chrono::steady_clock::now(), + std::memory_order_release); +} + +void SeventvEventAPIClient::checkHeartbeat() +{ + // Following the heartbeat docs, a connection is dead + // after three missed heartbeats. + // https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#heartbeat + assert(this->isStarted()); + if ((std::chrono::steady_clock::now() - this->lastHeartbeat_.load()) > + 3 * this->heartbeatInterval_) + { + qCDebug(chatterinoSeventvEventAPI) + << "Didn't receive a heartbeat in time, disconnecting!"; + this->close("Didn't receive a heartbeat in time"); + + return; + } + + auto self = std::dynamic_pointer_cast( + this->shared_from_this()); + + runAfter(this->websocketClient_.get_io_service(), this->heartbeatInterval_, + [self](auto) { + if (!self->isStarted()) + { + return; + } + self->checkHeartbeat(); + }); +} + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIClient.hpp b/src/providers/seventv/eventapi/SeventvEventAPIClient.hpp new file mode 100644 index 000000000..ffd4c766f --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIClient.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "providers/liveupdates/BasicPubSubClient.hpp" +#include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp" + +namespace chatterino { + +class SeventvEventAPIClient + : public BasicPubSubClient +{ +public: + SeventvEventAPIClient(liveupdates::WebsocketClient &websocketClient, + liveupdates::WebsocketHandle handle, + std::chrono::milliseconds heartbeatInterval); + + void setHeartbeatInterval(int intervalMs); + void handleHeartbeat(); + +protected: + void onConnectionEstablished() override; + +private: + void checkHeartbeat(); + + std::atomic> + lastHeartbeat_; + // This will be set once on the welcome message. + std::chrono::milliseconds heartbeatInterval_; + + friend class SeventvEventAPI; +}; + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIDispatch.cpp b/src/providers/seventv/eventapi/SeventvEventAPIDispatch.cpp new file mode 100644 index 000000000..0c46c8274 --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIDispatch.cpp @@ -0,0 +1,97 @@ +#include + +#include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp" + +namespace chatterino { + +SeventvEventAPIDispatch::SeventvEventAPIDispatch(QJsonObject obj) + : type(magic_enum::enum_cast( + obj["type"].toString().toStdString()) + .value_or(SeventvEventAPISubscriptionType::INVALID)) + , body(obj["body"].toObject()) + , id(this->body["id"].toString()) + , actorName(this->body["actor"].toObject()["display_name"].toString()) +{ +} + +SeventvEventAPIEmoteAddDispatch::SeventvEventAPIEmoteAddDispatch( + const SeventvEventAPIDispatch &dispatch, QJsonObject emote) + : emoteSetID(dispatch.id) + , actorName(dispatch.actorName) + , emoteJson(std::move(emote)) + , emoteID(this->emoteJson["id"].toString()) +{ +} + +bool SeventvEventAPIEmoteAddDispatch::validate() const +{ + bool validValues = + !this->emoteSetID.isEmpty() && !this->emoteJson.isEmpty(); + if (!validValues) + { + return false; + } + bool validActiveEmote = this->emoteJson.contains("id") && + this->emoteJson.contains("name") && + this->emoteJson.contains("data"); + if (!validActiveEmote) + { + return false; + } + auto emoteData = this->emoteJson["data"].toObject(); + return emoteData.contains("name") && emoteData.contains("host") && + emoteData.contains("owner"); +} + +SeventvEventAPIEmoteRemoveDispatch::SeventvEventAPIEmoteRemoveDispatch( + const SeventvEventAPIDispatch &dispatch, QJsonObject emote) + : emoteSetID(dispatch.id) + , actorName(dispatch.actorName) + , emoteName(emote["name"].toString()) + , emoteID(emote["id"].toString()) +{ +} + +bool SeventvEventAPIEmoteRemoveDispatch::validate() const +{ + return !this->emoteSetID.isEmpty() && !this->emoteName.isEmpty() && + !this->emoteID.isEmpty(); +} + +SeventvEventAPIEmoteUpdateDispatch::SeventvEventAPIEmoteUpdateDispatch( + const SeventvEventAPIDispatch &dispatch, QJsonObject oldValue, + QJsonObject value) + : emoteSetID(dispatch.id) + , actorName(dispatch.actorName) + , emoteID(value["id"].toString()) + , oldEmoteName(oldValue["name"].toString()) + , emoteName(value["name"].toString()) +{ +} + +bool SeventvEventAPIEmoteUpdateDispatch::validate() const +{ + return !this->emoteSetID.isEmpty() && !this->emoteID.isEmpty() && + !this->oldEmoteName.isEmpty() && !this->emoteName.isEmpty() && + this->oldEmoteName != this->emoteName; +} + +SeventvEventAPIUserConnectionUpdateDispatch:: + SeventvEventAPIUserConnectionUpdateDispatch( + const SeventvEventAPIDispatch &dispatch, const QJsonObject &update, + size_t connectionIndex) + : userID(dispatch.id) + , actorName(dispatch.actorName) + , oldEmoteSetID(update["old_value"].toObject()["id"].toString()) + , emoteSetID(update["value"].toObject()["id"].toString()) + , connectionIndex(connectionIndex) +{ +} + +bool SeventvEventAPIUserConnectionUpdateDispatch::validate() const +{ + return !this->userID.isEmpty() && !this->oldEmoteSetID.isEmpty() && + !this->emoteSetID.isEmpty(); +} + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIDispatch.hpp b/src/providers/seventv/eventapi/SeventvEventAPIDispatch.hpp new file mode 100644 index 000000000..a11b8471e --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIDispatch.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp" + +#include +#include + +namespace chatterino { + +// https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#message-payload +struct SeventvEventAPIDispatch { + SeventvEventAPISubscriptionType type; + QJsonObject body; + QString id; + // it's okay for this to be empty + QString actorName; + + SeventvEventAPIDispatch(QJsonObject obj); +}; + +struct SeventvEventAPIEmoteAddDispatch { + QString emoteSetID; + QString actorName; + QJsonObject emoteJson; + QString emoteID; + + SeventvEventAPIEmoteAddDispatch(const SeventvEventAPIDispatch &dispatch, + QJsonObject emote); + + bool validate() const; +}; + +struct SeventvEventAPIEmoteRemoveDispatch { + QString emoteSetID; + QString actorName; + QString emoteName; + QString emoteID; + + SeventvEventAPIEmoteRemoveDispatch(const SeventvEventAPIDispatch &dispatch, + QJsonObject emote); + + bool validate() const; +}; + +struct SeventvEventAPIEmoteUpdateDispatch { + QString emoteSetID; + QString actorName; + QString emoteID; + QString oldEmoteName; + QString emoteName; + + SeventvEventAPIEmoteUpdateDispatch(const SeventvEventAPIDispatch &dispatch, + QJsonObject oldValue, QJsonObject value); + + bool validate() const; +}; + +struct SeventvEventAPIUserConnectionUpdateDispatch { + QString userID; + QString actorName; + QString oldEmoteSetID; + QString emoteSetID; + size_t connectionIndex; + + SeventvEventAPIUserConnectionUpdateDispatch( + const SeventvEventAPIDispatch &dispatch, const QJsonObject &update, + size_t connectionIndex); + + bool validate() const; +}; + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIMessage.cpp b/src/providers/seventv/eventapi/SeventvEventAPIMessage.cpp new file mode 100644 index 000000000..69b3b2b22 --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIMessage.cpp @@ -0,0 +1,11 @@ +#include "providers/seventv/eventapi/SeventvEventAPIMessage.hpp" + +namespace chatterino { + +SeventvEventAPIMessage::SeventvEventAPIMessage(QJsonObject _json) + : data(_json["d"].toObject()) + , op(SeventvEventAPIOpcode(_json["op"].toInt())) +{ +} + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPIMessage.hpp b/src/providers/seventv/eventapi/SeventvEventAPIMessage.hpp new file mode 100644 index 000000000..5240df777 --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPIMessage.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "providers/seventv/SeventvEventAPI.hpp" + +#include +#include + +#include +#include +#include + +namespace chatterino { + +struct SeventvEventAPIMessage { + QJsonObject data; + + SeventvEventAPIOpcode op; + + SeventvEventAPIMessage(QJsonObject _json); + + template + boost::optional toInner(); +}; + +template +boost::optional SeventvEventAPIMessage::toInner() +{ + return InnerClass{this->data}; +} + +static boost::optional parseSeventvEventAPIBaseMessage( + const QString &blob) +{ + QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); + + if (jsonDoc.isNull()) + { + return boost::none; + } + + return SeventvEventAPIMessage(jsonDoc.object()); +} + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPISubscription.cpp b/src/providers/seventv/eventapi/SeventvEventAPISubscription.cpp new file mode 100644 index 000000000..a25e24f55 --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPISubscription.cpp @@ -0,0 +1,77 @@ +#include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp" + +#include +#include + +namespace { + +using namespace chatterino; + +const char *typeToString(SeventvEventAPISubscriptionType type) +{ + switch (type) + { + case SeventvEventAPISubscriptionType::UpdateEmoteSet: + return "emote_set.update"; + case SeventvEventAPISubscriptionType::UpdateUser: + return "user.update"; + default: + return ""; + } +} + +QJsonObject createDataJson(const char *typeName, const QString &condition) +{ + QJsonObject data; + data["type"] = typeName; + { + QJsonObject conditionObj; + conditionObj["object_id"] = condition; + data["condition"] = conditionObj; + } + return data; +} + +} // namespace + +namespace chatterino { + +bool SeventvEventAPISubscription::operator==( + const SeventvEventAPISubscription &rhs) const +{ + return std::tie(this->condition, this->type) == + std::tie(rhs.condition, rhs.type); +} + +bool SeventvEventAPISubscription::operator!=( + const SeventvEventAPISubscription &rhs) const +{ + return !(rhs == *this); +} + +QByteArray SeventvEventAPISubscription::encodeSubscribe() const +{ + const auto *typeName = typeToString(this->type); + QJsonObject root; + root["op"] = (int)SeventvEventAPIOpcode::Subscribe; + root["d"] = createDataJson(typeName, this->condition); + return QJsonDocument(root).toJson(); +} + +QByteArray SeventvEventAPISubscription::encodeUnsubscribe() const +{ + const auto *typeName = typeToString(this->type); + QJsonObject root; + root["op"] = (int)SeventvEventAPIOpcode::Unsubscribe; + root["d"] = createDataJson(typeName, this->condition); + return QJsonDocument(root).toJson(); +} + +QDebug &operator<<(QDebug &dbg, const SeventvEventAPISubscription &subscription) +{ + dbg << "SeventvEventAPISubscription{ condition:" << subscription.condition + << "type:" << (int)subscription.type << '}'; + return dbg; +} + +} // namespace chatterino diff --git a/src/providers/seventv/eventapi/SeventvEventAPISubscription.hpp b/src/providers/seventv/eventapi/SeventvEventAPISubscription.hpp new file mode 100644 index 000000000..7270df36a --- /dev/null +++ b/src/providers/seventv/eventapi/SeventvEventAPISubscription.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include + +#include +#include +#include + +namespace chatterino { + +// https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#subscription-types +enum class SeventvEventAPISubscriptionType { + UpdateEmoteSet, + UpdateUser, + + INVALID, +}; + +// https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#opcodes +enum class SeventvEventAPIOpcode { + Dispatch = 0, + Hello = 1, + Heartbeat = 2, + Reconnect = 4, + Ack = 5, + Error = 6, + EndOfStream = 7, + Identify = 33, + Resume = 34, + Subscribe = 35, + Unsubscribe = 36, + Signal = 37, +}; + +struct SeventvEventAPISubscription { + bool operator==(const SeventvEventAPISubscription &rhs) const; + bool operator!=(const SeventvEventAPISubscription &rhs) const; + QString condition; + SeventvEventAPISubscriptionType type; + + QByteArray encodeSubscribe() const; + QByteArray encodeUnsubscribe() const; + + friend QDebug &operator<<(QDebug &dbg, + const SeventvEventAPISubscription &subscription); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::SeventvEventAPISubscriptionType>( + chatterino::SeventvEventAPISubscriptionType value) noexcept +{ + switch (value) + { + case chatterino::SeventvEventAPISubscriptionType::UpdateEmoteSet: + return "emote_set.update"; + case chatterino::SeventvEventAPISubscriptionType::UpdateUser: + return "user.update"; + + default: + return default_tag; + } +} + +namespace std { + +template <> +struct hash { + size_t operator()(const chatterino::SeventvEventAPISubscription &sub) const + { + return (size_t)qHash(sub.condition, qHash((int)sub.type)); + } +}; + +} // namespace std diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 5b1a3a75c..bd1eddb0f 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -11,6 +11,7 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/LoadBttvChannelEmote.hpp" #include "providers/seventv/SeventvEmotes.hpp" +#include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchCommon.hpp" @@ -104,6 +105,11 @@ TwitchChannel::TwitchChannel(const QString &name) this->loadRecentMessagesReconnect(); }); + this->destroyed.connect([this]() { + getApp()->twitch->dropSeventvChannel(this->seventvUserID_, + this->seventvEmoteSetID_); + }); + this->messageRemovedFromStart.connect([this](MessagePtr &msg) { if (msg->replyThread) { @@ -237,11 +243,16 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh) SeventvEmotes::loadChannelEmotes( weakOf(this), this->roomId(), - [this, weak = weakOf(this)](auto &&emoteMap) { + [this, weak = weakOf(this)](auto &&emoteMap, + auto channelInfo) { if (auto shared = weak.lock()) { this->seventvEmotes_.set(std::make_shared( std::forward(emoteMap))); + this->updateSeventvData(channelInfo.userID, + channelInfo.emoteSetID); + this->seventvUserTwitchConnectionIndex_ = + channelInfo.twitchConnectionIndex; } }, manualRefresh); @@ -589,6 +600,203 @@ std::shared_ptr TwitchChannel::seventvEmotes() const return this->seventvEmotes_.get(); } +const QString &TwitchChannel::seventvUserID() const +{ + return this->seventvUserID_; +} +const QString &TwitchChannel::seventvEmoteSetID() const +{ + return this->seventvEmoteSetID_; +} + +void TwitchChannel::addSeventvEmote( + const SeventvEventAPIEmoteAddDispatch &dispatch) +{ + if (!SeventvEmotes::addEmote(this->seventvEmotes_, dispatch)) + { + return; + } + + this->addOrReplaceLiveUpdatesAddRemove( + true, "7TV", dispatch.actorName, dispatch.emoteJson["name"].toString()); +} + +void TwitchChannel::updateSeventvEmote( + const SeventvEventAPIEmoteUpdateDispatch &dispatch) +{ + if (!SeventvEmotes::updateEmote(this->seventvEmotes_, dispatch)) + { + return; + } + + auto builder = + MessageBuilder(liveUpdatesUpdateEmoteMessage, "7TV", dispatch.actorName, + dispatch.emoteName, dispatch.oldEmoteName); + this->addMessage(builder.release()); +} + +void TwitchChannel::removeSeventvEmote( + const SeventvEventAPIEmoteRemoveDispatch &dispatch) +{ + auto removed = SeventvEmotes::removeEmote(this->seventvEmotes_, dispatch); + if (!removed) + { + return; + } + + this->addOrReplaceLiveUpdatesAddRemove(false, "7TV", dispatch.actorName, + removed.get()->name.string); +} + +void TwitchChannel::updateSeventvUser( + const SeventvEventAPIUserConnectionUpdateDispatch &dispatch) +{ + if (dispatch.connectionIndex != this->seventvUserTwitchConnectionIndex_) + { + // A different connection was updated + return; + } + + updateSeventvData(this->seventvUserID_, dispatch.emoteSetID); + SeventvEmotes::getEmoteSet( + dispatch.emoteSetID, + [this, weak = weakOf(this), dispatch](auto &&emotes, + const auto &name) { + postToThread([this, weak, dispatch, emotes, name]() { + if (auto shared = weak.lock()) + { + this->seventvEmotes_.set( + std::make_shared(emotes)); + auto builder = + MessageBuilder(liveUpdatesUpdateEmoteSetMessage, "7TV", + dispatch.actorName, name); + this->addMessage(builder.release()); + } + }); + }, + [this, weak = weakOf(this)](const auto &reason) { + postToThread([this, weak, reason]() { + if (auto shared = weak.lock()) + { + this->seventvEmotes_.set(EMPTY_EMOTE_MAP); + this->addMessage(makeSystemMessage( + QString("Failed updating 7TV emote set (%1).") + .arg(reason))); + } + }); + }); +} + +void TwitchChannel::updateSeventvData(const QString &newUserID, + const QString &newEmoteSetID) +{ + if (this->seventvUserID_ == newUserID && + this->seventvEmoteSetID_ == newEmoteSetID) + { + return; + } + + boost::optional oldUserID = boost::make_optional( + !this->seventvUserID_.isEmpty() && this->seventvUserID_ != newUserID, + this->seventvUserID_); + boost::optional oldEmoteSetID = + boost::make_optional(!this->seventvEmoteSetID_.isEmpty() && + this->seventvEmoteSetID_ != newEmoteSetID, + this->seventvEmoteSetID_); + + this->seventvUserID_ = newUserID; + this->seventvEmoteSetID_ = newEmoteSetID; + runInGuiThread([this, oldUserID, oldEmoteSetID]() { + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->subscribeUser( + this->seventvUserID_, this->seventvEmoteSetID_); + + if (oldUserID || oldEmoteSetID) + { + getApp()->twitch->dropSeventvChannel( + oldUserID.get_value_or(QString()), + oldEmoteSetID.get_value_or(QString())); + } + } + }); +} + +void TwitchChannel::addOrReplaceLiveUpdatesAddRemove(bool isEmoteAdd, + const QString &platform, + const QString &actor, + const QString &emoteName) +{ + if (this->tryReplaceLastLiveUpdateAddOrRemove( + isEmoteAdd ? MessageFlag::LiveUpdatesAdd + : MessageFlag::LiveUpdatesRemove, + platform, actor, emoteName)) + { + return; + } + + this->lastLiveUpdateEmoteNames_ = {emoteName}; + + MessagePtr msg; + if (isEmoteAdd) + { + msg = MessageBuilder(liveUpdatesAddEmoteMessage, platform, actor, + this->lastLiveUpdateEmoteNames_) + .release(); + } + else + { + msg = MessageBuilder(liveUpdatesRemoveEmoteMessage, platform, actor, + this->lastLiveUpdateEmoteNames_) + .release(); + } + this->lastLiveUpdateEmotePlatform_ = platform; + this->lastLiveUpdateMessage_ = msg; + this->lastLiveUpdateEmoteActor_ = actor; + this->addMessage(msg); +} + +bool TwitchChannel::tryReplaceLastLiveUpdateAddOrRemove( + MessageFlag op, const QString &platform, const QString &actor, + const QString &emoteName) +{ + if (this->lastLiveUpdateEmotePlatform_ != platform) + { + return false; + } + auto last = this->lastLiveUpdateMessage_.lock(); + if (!last || !last->flags.has(op) || + last->parseTime < QTime::currentTime().addSecs(-5) || + last->loginName != actor) + { + return false; + } + // Update the message + this->lastLiveUpdateEmoteNames_.push_back(emoteName); + + MessageBuilder replacement; + if (op == MessageFlag::LiveUpdatesAdd) + { + replacement = + MessageBuilder(liveUpdatesAddEmoteMessage, platform, + last->loginName, this->lastLiveUpdateEmoteNames_); + } + else // op == RemoveEmoteMessage + { + replacement = + MessageBuilder(liveUpdatesRemoveEmoteMessage, platform, + last->loginName, this->lastLiveUpdateEmoteNames_); + } + + replacement->flags = last->flags; + + auto msg = replacement.release(); + this->lastLiveUpdateMessage_ = msg; + this->replaceMessage(last, msg); + + return true; +} + const QString &TwitchChannel::subscriptionUrl() { return this->subscriptionUrl_; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 8b5db0e32..5d0442f22 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -9,6 +9,7 @@ #include "common/Outcome.hpp" #include "common/UniqueAccess.hpp" #include "messages/MessageThread.hpp" +#include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "providers/twitch/api/Helix.hpp" @@ -119,6 +120,23 @@ public: virtual void refreshFFZChannelEmotes(bool manualRefresh); virtual void refreshSevenTVChannelEmotes(bool manualRefresh); + const QString &seventvUserID() const; + const QString &seventvEmoteSetID() const; + + /** Adds a 7TV channel emote to this channel. */ + void addSeventvEmote(const SeventvEventAPIEmoteAddDispatch &dispatch); + /** Updates a 7TV channel emote's name in this channel */ + void updateSeventvEmote(const SeventvEventAPIEmoteUpdateDispatch &dispatch); + /** Removes a 7TV channel emote from this channel */ + void removeSeventvEmote(const SeventvEventAPIEmoteRemoveDispatch &dispatch); + /** Updates the current 7TV user. Currently, only the emote-set is updated. */ + void updateSeventvUser( + const SeventvEventAPIUserConnectionUpdateDispatch &dispatch); + + // Update the channel's 7TV information (the channel's 7TV user ID and emote set ID) + void updateSeventvData(const QString &newUserID, + const QString &newEmoteSetID); + // Badges boost::optional ffzCustomModBadge() const; boost::optional ffzCustomVipBadge() const; @@ -187,6 +205,41 @@ private: QString prepareMessage(const QString &message) const; + /** + * Either adds a message mentioning the updated emotes + * or replaces an existing message. For criteria on existing messages, + * see `tryReplaceLastLiveUpdateAddOrRemove`. + * + * @param isEmoteAdd true if the emote was added, false if it was removed. + * @param platform The platform the emote was updated on ("7TV", "BTTV", "FFZ") + * @param actor The actor performing the update (possibly empty) + * @param emoteName The emote's name + */ + void addOrReplaceLiveUpdatesAddRemove(bool isEmoteAdd, + const QString &platform, + const QString &actor, + const QString &emoteName); + + /** + * Tries to replace the last emote update message. + * + * A last message is valid if: + * * The actors match + * * The operations match + * * The platform matches + * * The last message isn't older than 5s + * + * @param op The emote operation (LiveUpdatesAdd or LiveUpdatesRemove) + * @param platform The emote platform ("7TV", "BTTV", "FFZ") + * @param actor The actor performing the action (possibly empty) + * @param emoteName The updated emote's name + * @return true, if the last message was replaced + */ + bool tryReplaceLastLiveUpdateAddOrRemove(MessageFlag op, + const QString &platform, + const QString &actor, + const QString &emoteName); + // Data const QString subscriptionUrl_; const QString channelUrl_; @@ -225,6 +278,31 @@ private: QElapsedTimer clipCreationTimer_; bool isClipCreationInProgress{false}; + /** + * This channels 7TV user-id, + * empty if this channel isn't connected with 7TV. + */ + QString seventvUserID_; + /** + * This channels current 7TV emote-set-id, + * empty if this channel isn't connected with 7TV + */ + QString seventvEmoteSetID_; + /** + * The index of the twitch connection in + * 7TV's user representation. + */ + size_t seventvUserTwitchConnectionIndex_; + + /** The platform of the last live emote update ("7TV", "BTTV", "FFZ"). */ + QString lastLiveUpdateEmotePlatform_; + /** The actor name of the last live emote update. */ + QString lastLiveUpdateEmoteActor_; + /** A weak reference to the last live emote update message. */ + std::weak_ptr lastLiveUpdateMessage_; + /** A list of the emotes listed in the lat live emote update message. */ + std::vector lastLiveUpdateEmoteNames_; + pajlada::Signals::SignalHolder signalHolder_; std::vector bSignals_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index c10b788af..6006eb802 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -10,11 +10,13 @@ #include "controllers/accounts/AccountController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchHelpers.hpp" +#include "singletons/Settings.hpp" #include "util/Helpers.hpp" #include "util/PostToThread.hpp" @@ -25,6 +27,12 @@ using namespace std::chrono_literals; #define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv" +namespace { + +const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3"; + +} // namespace + namespace chatterino { TwitchIrcServer::TwitchIrcServer() @@ -36,6 +44,12 @@ TwitchIrcServer::TwitchIrcServer() this->initializeIrc(); this->pubsub = new PubSub(TWITCH_PUBSUB_URL); + if (getSettings()->enableSevenTVEventAPI && + getSettings()->enableSevenTVChannelEmotes) + { + this->seventvEventAPI = + std::make_unique(SEVENTV_EVENTAPI_URL); + } // getSettings()->twitchSeperateWriteConnection.connect([this](auto, auto) { // this->connect(); }, @@ -517,4 +531,78 @@ void TwitchIrcServer::reloadAllSevenTVChannelEmotes() } }); } + +void TwitchIrcServer::forEachSeventvEmoteSet( + const QString &emoteSetId, std::function func) +{ + this->forEachChannel([emoteSetId, func](const auto &chan) { + if (auto *channel = dynamic_cast(chan.get()); + channel->seventvEmoteSetID() == emoteSetId) + { + func(*channel); + } + }); +} +void TwitchIrcServer::forEachSeventvUser( + const QString &userId, std::function func) +{ + this->forEachChannel([userId, func](const auto &chan) { + if (auto *channel = dynamic_cast(chan.get()); + channel->seventvUserID() == userId) + { + func(*channel); + } + }); +} + +void TwitchIrcServer::dropSeventvChannel(const QString &userID, + const QString &emoteSetID) +{ + if (!this->seventvEventAPI) + { + return; + } + + std::lock_guard lock(this->channelMutex); + + // ignore empty values + bool skipUser = userID.isEmpty(); + bool skipSet = emoteSetID.isEmpty(); + + bool foundUser = skipUser; + bool foundSet = skipSet; + for (std::weak_ptr &weak : this->channels) + { + ChannelPtr chan = weak.lock(); + if (!chan) + { + continue; + } + + auto *channel = dynamic_cast(chan.get()); + if (!foundSet && channel->seventvEmoteSetID() == emoteSetID) + { + foundSet = true; + } + if (!foundUser && channel->seventvUserID() == userID) + { + foundUser = true; + } + + if (foundSet && foundUser) + { + break; + } + } + + if (!foundUser) + { + this->seventvEventAPI->unsubscribeUser(userID); + } + if (!foundSet) + { + this->seventvEventAPI->unsubscribeEmoteSet(emoteSetID); + } +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index dc5667c5a..572b2bb13 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -19,6 +19,7 @@ class Settings; class Paths; class PubSub; class TwitchChannel; +class SeventvEventAPI; class TwitchIrcServer final : public AbstractIrcServer, public Singleton { @@ -41,6 +42,21 @@ public: void reloadSevenTVGlobalEmotes(); void reloadAllSevenTVChannelEmotes(); + /** Calls `func` with all twitch channels that have `emoteSetId` added. */ + void forEachSeventvEmoteSet(const QString &emoteSetId, + std::function func); + /** Calls `func` with all twitch channels where the seventv-user-id is `userId`. */ + void forEachSeventvUser(const QString &userId, + std::function func); + /** + * Checks if any channel still needs this `userID` or `emoteSetID`. + * If not, it unsubscribes from the respective messages. + * + * It's currently not possible to share emote sets among users, + * but it's a commonly requested feature. + */ + void dropSeventvChannel(const QString &userID, const QString &emoteSetID); + Atomic lastUserThatWhisperedMe; const ChannelPtr whispersChannel; @@ -49,6 +65,7 @@ public: IndirectChannel watchingChannel; PubSub *pubsub; + std::unique_ptr seventvEventAPI; const BttvEmotes &getBttvEmotes() const; const FfzEmotes &getFfzEmotes() const; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index e63484ec1..b426c4257 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -224,6 +224,7 @@ public: BoolSetting enableFFZChannelEmotes = {"/emotes/ffz/channel", true}; BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true}; + BoolSetting enableSevenTVEventAPI = {"/emotes/seventv/eventapi", true}; /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; diff --git a/src/util/PostToThread.hpp b/src/util/PostToThread.hpp index 45d715a1d..4f7f872d9 100644 --- a/src/util/PostToThread.hpp +++ b/src/util/PostToThread.hpp @@ -30,19 +30,6 @@ private: std::function action_; }; -template -static void runInGuiThread(F &&fun) -{ - if (isGuiThread()) - { - fun(); - } - else - { - postToThread(fun); - } -} - // Taken from // https://stackoverflow.com/questions/21646467/how-to-execute-a-functor-or-a-lambda-in-a-given-thread-in-qt-gcd-style // Qt 5/4 - preferred, has least allocations @@ -70,4 +57,17 @@ static void postToThread(F &&fun, QObject *obj = qApp) QCoreApplication::postEvent(obj, new Event(std::forward(fun))); } +template +static void runInGuiThread(F &&fun) +{ + if (isGuiThread()) + { + fun(); + } + else + { + postToThread(fun); + } +} + } // namespace chatterino diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 4f827d8c5..c10e9eece 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -387,6 +387,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Show FFZ channel emotes", s.enableFFZChannelEmotes); layout.addCheckbox("Show 7TV global emotes", s.enableSevenTVGlobalEmotes); layout.addCheckbox("Show 7TV channel emotes", s.enableSevenTVChannelEmotes); + layout.addCheckbox("Enable 7TV live emote updates (requires restart)", + s.enableSevenTVEventAPI); layout.addTitle("Streamer Mode"); layout.addDescription( diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e71dfd3ee..8887753dd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp ${CMAKE_CURRENT_LIST_DIR}/src/BasicPubSub.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp # Add your new file above this line! ) diff --git a/tests/src/SeventvEventAPI.cpp b/tests/src/SeventvEventAPI.cpp new file mode 100644 index 000000000..4731dc065 --- /dev/null +++ b/tests/src/SeventvEventAPI.cpp @@ -0,0 +1,103 @@ +#include "providers/seventv/SeventvEventAPI.hpp" + +#include +#include +#include + +using namespace chatterino; +using namespace std::chrono_literals; + +const QString EMOTE_SET_A = "60b39e943e203cc169dfc106"; +const QString EMOTE_SET_B = "60bca831e7ecd2f892c9b9ab"; +const QString TARGET_USER_ID = "60b39e943e203cc169dfc106"; + +TEST(SeventvEventAPI, AllEvents) +{ + const QString host("wss://127.0.0.1:9050/liveupdates/seventv/all-events"); + auto *eventAPI = new SeventvEventAPI(host, std::chrono::milliseconds(1000)); + eventAPI->start(); + + boost::optional addDispatch; + boost::optional updateDispatch; + boost::optional removeDispatch; + boost::optional userDispatch; + + eventAPI->signals_.emoteAdded.connect([&](const auto &d) { + addDispatch = d; + }); + eventAPI->signals_.emoteUpdated.connect([&](const auto &d) { + updateDispatch = d; + }); + eventAPI->signals_.emoteRemoved.connect([&](const auto &d) { + removeDispatch = d; + }); + eventAPI->signals_.userUpdated.connect([&](const auto &d) { + userDispatch = d; + }); + + std::this_thread::sleep_for(50ms); + eventAPI->subscribeUser("", EMOTE_SET_A); + std::this_thread::sleep_for(500ms); + + ASSERT_EQ(eventAPI->diag.connectionsOpened, 1); + ASSERT_EQ(eventAPI->diag.connectionsClosed, 0); + ASSERT_EQ(eventAPI->diag.connectionsFailed, 0); + + auto add = *addDispatch; + ASSERT_EQ(add.emoteSetID, EMOTE_SET_A); + ASSERT_EQ(add.actorName, QString("nerixyz")); + ASSERT_EQ(add.emoteID, QString("621d13967cc2d4e1953838ed")); + + auto upd = *updateDispatch; + ASSERT_EQ(upd.emoteSetID, EMOTE_SET_A); + ASSERT_EQ(upd.actorName, QString("nerixyz")); + ASSERT_EQ(upd.emoteID, QString("621d13967cc2d4e1953838ed")); + ASSERT_EQ(upd.oldEmoteName, QString("Chatterinoge")); + ASSERT_EQ(upd.emoteName, QString("Chatterino")); + + auto rem = *removeDispatch; + ASSERT_EQ(rem.emoteSetID, EMOTE_SET_A); + ASSERT_EQ(rem.actorName, QString("nerixyz")); + ASSERT_EQ(rem.emoteName, QString("Chatterino")); + ASSERT_EQ(rem.emoteID, QString("621d13967cc2d4e1953838ed")); + + ASSERT_EQ(userDispatch.has_value(), false); + addDispatch = boost::none; + updateDispatch = boost::none; + removeDispatch = boost::none; + + eventAPI->subscribeUser(TARGET_USER_ID, ""); + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(addDispatch.has_value(), false); + ASSERT_EQ(updateDispatch.has_value(), false); + ASSERT_EQ(removeDispatch.has_value(), false); + + auto user = *userDispatch; + ASSERT_EQ(user.userID, TARGET_USER_ID); + ASSERT_EQ(user.actorName, QString("nerixyz")); + ASSERT_EQ(user.oldEmoteSetID, EMOTE_SET_A); + ASSERT_EQ(user.emoteSetID, EMOTE_SET_B); + ASSERT_EQ(user.connectionIndex, 0); + + eventAPI->stop(); + ASSERT_EQ(eventAPI->diag.connectionsOpened, 1); + ASSERT_EQ(eventAPI->diag.connectionsClosed, 1); + ASSERT_EQ(eventAPI->diag.connectionsFailed, 0); +} + +TEST(SeventvEventAPI, NoHeartbeat) +{ + const QString host("wss://127.0.0.1:9050/liveupdates/seventv/no-heartbeat"); + auto *eventApi = new SeventvEventAPI(host, std::chrono::milliseconds(1000)); + eventApi->start(); + + std::this_thread::sleep_for(50ms); + eventApi->subscribeUser("", EMOTE_SET_A); + std::this_thread::sleep_for(1250ms); + ASSERT_EQ(eventApi->diag.connectionsOpened, 2); + ASSERT_EQ(eventApi->diag.connectionsClosed, 1); + ASSERT_EQ(eventApi->diag.connectionsFailed, 0); + + eventApi->stop(); +}