diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c848b1b6..84aae8c17 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.5 + TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:master concurrency: group: test-${{ github.ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c8fbef0..044754c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added live emote updates for BTTV (#4147) - Minor: Change the highlight order to prioritize Message highlights over User highlights. (#4303) - Minor: Added ability to negate search options by prefixing it with an exclamation mark (e.g. `!badge:mod` to search for messages where the author does not have the moderator badge). (#4207) - Minor: Search window input will automatically use currently selected text if present. (#4178) diff --git a/src/Application.cpp b/src/Application.cpp index c1bdf2c9a..1d3207e75 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -14,6 +14,7 @@ #include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/bttv/BttvLiveUpdates.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/ffz/FfzBadges.hpp" #include "providers/irc/Irc2.hpp" @@ -156,6 +157,7 @@ void Application::initialize(Settings &settings, Paths &paths) } this->initPubSub(); + this->initBttvLiveUpdates(); this->initSeventvEventAPI(); } @@ -576,6 +578,51 @@ void Application::initPubSub() RequestModerationActions(); } +void Application::initBttvLiveUpdates() +{ + if (!this->twitch->bttvLiveUpdates) + { + qCDebug(chatterinoBttv) + << "Skipping initialization of Live Updates as it's disabled"; + return; + } + + this->twitch->bttvLiveUpdates->signals_.emoteAdded.connect( + [&](const auto &data) { + auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); + + postToThread([chan, data] { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->addBttvEmote(data); + } + }); + }); + this->twitch->bttvLiveUpdates->signals_.emoteUpdated.connect( + [&](const auto &data) { + auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); + + postToThread([chan, data] { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->updateBttvEmote(data); + } + }); + }); + this->twitch->bttvLiveUpdates->signals_.emoteRemoved.connect( + [&](const auto &data) { + auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); + + postToThread([chan, data] { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->removeBttvEmote(data); + } + }); + }); + this->twitch->bttvLiveUpdates->start(); +} + void Application::initSeventvEventAPI() { if (!this->twitch->seventvEventAPI) diff --git a/src/Application.hpp b/src/Application.hpp index edb571f16..9620c4960 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -149,6 +149,7 @@ public: private: void addSingleton(Singleton *singleton); void initPubSub(); + void initBttvLiveUpdates(); void initSeventvEventAPI(); void initNm(Paths &paths); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 530212925..2244449dd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -197,6 +197,13 @@ set(SOURCE_FILES providers/bttv/BttvEmotes.cpp providers/bttv/BttvEmotes.hpp + providers/bttv/BttvLiveUpdates.cpp + providers/bttv/BttvLiveUpdates.hpp + + providers/bttv/liveupdates/BttvLiveUpdateMessages.cpp + providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp + providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp + providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp providers/chatterino/ChatterinoBadges.cpp providers/chatterino/ChatterinoBadges.hpp diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 871e6c343..d97b12e24 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -593,17 +593,37 @@ MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteMessageTag /*unused*/, const QString &oldEmoteName) : MessageBuilder() { - auto text = QString("renamed %1 emote %2 to %3.") - .arg(platform, oldEmoteName, emoteName); + QString text; + if (actor.isEmpty()) + { + text = "Renamed"; + } + else + { + text = "renamed"; + } + text += + QString(" %1 emote %2 to %3.").arg(platform, oldEmoteName, emoteName); this->emplace(); - this->emplace(actor, MessageElementFlag::Username, - MessageColor::System) - ->setLink({Link::UserInfo, actor}); + if (!actor.isEmpty()) + { + 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); + QString finalText; + if (actor.isEmpty()) + { + finalText = text; + } + else + { + finalText = QString("%1 %2").arg(actor, text); + } this->message().loginName = actor; this->message().messageText = finalText; diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index a65d99f33..f214d4177 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -7,6 +7,7 @@ #include "messages/Image.hpp" #include "messages/ImageSet.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" @@ -21,6 +22,12 @@ namespace { QString emoteLinkFormat("https://betterttv.com/emotes/%1"); + struct CreateEmoteResult { + EmoteId id; + EmoteName name; + Emote emote; + }; + Url getEmoteLink(QString urlTemplate, const EmoteId &id, const QString &emoteScale) { @@ -70,6 +77,70 @@ namespace { return {Success, std::move(emotes)}; } + + CreateEmoteResult createChannelEmote(const QString &channelDisplayName, + const QJsonObject &jsonEmote) + { + auto id = EmoteId{jsonEmote.value("id").toString()}; + auto name = EmoteName{jsonEmote.value("code").toString()}; + auto author = EmoteAuthor{ + jsonEmote.value("user").toObject().value("displayName").toString()}; + + auto emote = Emote({ + name, + ImageSet{ + Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), + Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), + Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25), + }, + Tooltip{ + QString("%1
%2 BetterTTV Emote
By: %3") + .arg(name.string) + // when author is empty, it is a channel emote created by the broadcaster + .arg(author.string.isEmpty() ? "Channel" : "Shared") + .arg(author.string.isEmpty() ? channelDisplayName + : author.string)}, + Url{emoteLinkFormat.arg(id.string)}, + false, + id, + }); + + return {id, name, emote}; + } + + bool updateChannelEmote(Emote &emote, const QString &channelDisplayName, + const QJsonObject &jsonEmote) + { + bool anyModifications = false; + + if (jsonEmote.contains("code")) + { + emote.name = EmoteName{jsonEmote.value("code").toString()}; + anyModifications = true; + } + if (jsonEmote.contains("user")) + { + emote.author = EmoteAuthor{jsonEmote.value("user") + .toObject() + .value("displayName") + .toString()}; + anyModifications = true; + } + + if (anyModifications) + { + emote.tooltip = Tooltip{ + QString("%1
%2 BetterTTV Emote
By: %3") + .arg(emote.name.string) + // when author is empty, it is a channel emote created by the broadcaster + .arg(emote.author.string.isEmpty() ? "Channel" : "Shared") + .arg(emote.author.string.isEmpty() ? channelDisplayName + : emote.author.string)}; + } + + return anyModifications; + } + std::pair parseChannelEmotes( const QJsonObject &jsonRoot, const QString &channelDisplayName) { @@ -80,33 +151,11 @@ namespace { auto jsonEmotes = jsonRoot.value(key).toArray(); for (auto jsonEmote_ : jsonEmotes) { - auto jsonEmote = jsonEmote_.toObject(); + auto emote = createChannelEmote(channelDisplayName, + jsonEmote_.toObject()); - auto id = EmoteId{jsonEmote.value("id").toString()}; - auto name = EmoteName{jsonEmote.value("code").toString()}; - auto author = EmoteAuthor{jsonEmote.value("user") - .toObject() - .value("displayName") - .toString()}; - - auto emote = Emote({ - name, - ImageSet{ - Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), - Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), - Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25), - }, - Tooltip{ - QString("%1
%2 BetterTTV Emote
By: %3") - .arg(name.string) - // when author is empty, it is a channel emote created by the broadcaster - .arg(author.string.isEmpty() ? "Channel" : "Shared") - .arg(author.string.isEmpty() ? channelDisplayName - : author.string)}, - Url{emoteLinkFormat.arg(id.string)}, - }); - - emotes[name] = cachedOrMake(std::move(emote), id); + emotes[emote.name] = + cachedOrMake(std::move(emote.emote), emote.id); } }; @@ -227,6 +276,78 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, .execute(); } +EmotePtr BttvEmotes::addEmote( + const QString &channelDisplayName, + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteUpdateAddMessage &message) +{ + // This copies the map. + EmoteMap updatedMap = *channelEmoteMap.get(); + auto result = createChannelEmote(channelDisplayName, message.jsonEmote); + + auto emote = std::make_shared(std::move(result.emote)); + updatedMap[result.name] = emote; + channelEmoteMap.set(std::make_shared(std::move(updatedMap))); + + return emote; +} + +boost::optional> BttvEmotes::updateEmote( + const QString &channelDisplayName, + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteUpdateAddMessage &message) +{ + // This copies the map. + EmoteMap updatedMap = *channelEmoteMap.get(); + + // Step 1: remove the existing emote + auto it = updatedMap.findEmote(QString(), message.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 oldEmotePtr = it->second; + // copy the existing emote, to not change the original one + auto emote = *oldEmotePtr; + updatedMap.erase(it); + + // Step 2: update the emote + if (!updateChannelEmote(emote, channelDisplayName, message.jsonEmote)) + { + // The emote wasn't actually updated + return boost::none; + } + + auto name = emote.name; + auto emotePtr = std::make_shared(std::move(emote)); + updatedMap[name] = emotePtr; + channelEmoteMap.set(std::make_shared(std::move(updatedMap))); + + return std::make_pair(oldEmotePtr, emotePtr); +} + +boost::optional BttvEmotes::removeEmote( + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteRemoveMessage &message) +{ + // This copies the map. + EmoteMap updatedMap = *channelEmoteMap.get(); + auto it = updatedMap.findEmote(QString(), message.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); + channelEmoteMap.set(std::make_shared(std::move(updatedMap))); + + return emote; +} + /* static Url getEmoteLink(QString urlTemplate, const EmoteId &id, const QString &emoteScale) diff --git a/src/providers/bttv/BttvEmotes.hpp b/src/providers/bttv/BttvEmotes.hpp index 69340ad99..bca2d4b65 100644 --- a/src/providers/bttv/BttvEmotes.hpp +++ b/src/providers/bttv/BttvEmotes.hpp @@ -13,6 +13,8 @@ struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; class Channel; +struct BttvLiveUpdateEmoteUpdateAddMessage; +struct BttvLiveUpdateEmoteRemoveMessage; class BttvEmotes final { @@ -33,6 +35,41 @@ public: std::function callback, bool manualRefresh); + /** + * Adds an emote to the `channelEmoteMap`. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return The added emote. + */ + static EmotePtr addEmote( + const QString &channelDisplayName, + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteUpdateAddMessage &message); + + /** + * Updates an emote in this `channelEmoteMap`. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return pair if any emote was updated. + */ + static boost::optional> updateEmote( + const QString &channelDisplayName, + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteUpdateAddMessage &message); + + /** + * Removes an emote from this `channelEmoteMap`. + * This will _copy_ the emote map and + * update the `Atomic`. + * + * @return The removed emote if any emote was removed. + */ + static boost::optional removeEmote( + Atomic> &channelEmoteMap, + const BttvLiveUpdateEmoteRemoveMessage &message); + private: Atomic> global_; }; diff --git a/src/providers/bttv/BttvLiveUpdates.cpp b/src/providers/bttv/BttvLiveUpdates.cpp new file mode 100644 index 000000000..1a0752297 --- /dev/null +++ b/src/providers/bttv/BttvLiveUpdates.cpp @@ -0,0 +1,92 @@ +#include "providers/bttv/BttvLiveUpdates.hpp" + +#include + +#include + +namespace chatterino { + +BttvLiveUpdates::BttvLiveUpdates(QString host) + : BasicPubSubManager(std::move(host)) +{ +} + +void BttvLiveUpdates::joinChannel(const QString &channelID, + const QString &userName) +{ + if (this->joinedChannels_.insert(channelID).second) + { + this->subscribe({BttvLiveUpdateSubscriptionChannel{channelID}}); + this->subscribe({BttvLiveUpdateBroadcastMe{.twitchID = channelID, + .userName = userName}}); + } +} + +void BttvLiveUpdates::partChannel(const QString &id) +{ + if (this->joinedChannels_.erase(id) > 0) + { + this->unsubscribe({BttvLiveUpdateSubscriptionChannel{id}}); + } +} + +void BttvLiveUpdates::onMessage( + websocketpp::connection_hdl /*hdl*/, + BasicPubSubManager::WebsocketMessagePtr msg) +{ + const auto &payload = QString::fromStdString(msg->get_payload()); + QJsonDocument jsonDoc(QJsonDocument::fromJson(payload.toUtf8())); + + if (jsonDoc.isNull()) + { + qCDebug(chatterinoBttv) << "Failed to parse live update JSON"; + return; + } + auto json = jsonDoc.object(); + + auto eventType = json["name"].toString(); + auto eventData = json["data"].toObject(); + + if (eventType == "emote_create") + { + auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData); + + if (!message.validate()) + { + qCDebug(chatterinoBttv) << "Invalid add message" << json; + return; + } + + this->signals_.emoteAdded.invoke(message); + } + else if (eventType == "emote_update") + { + auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData); + + if (!message.validate()) + { + qCDebug(chatterinoBttv) << "Invalid update message" << json; + return; + } + + this->signals_.emoteUpdated.invoke(message); + } + else if (eventType == "emote_delete") + { + auto message = BttvLiveUpdateEmoteRemoveMessage(eventData); + + if (!message.validate()) + { + qCDebug(chatterinoBttv) << "Invalid deletion message" << json; + return; + } + + this->signals_.emoteRemoved.invoke(message); + } + else + { + qCDebug(chatterinoBttv) << "Unhandled event:" << json; + } +} + +} // namespace chatterino diff --git a/src/providers/bttv/BttvLiveUpdates.hpp b/src/providers/bttv/BttvLiveUpdates.hpp new file mode 100644 index 000000000..c5206ab2b --- /dev/null +++ b/src/providers/bttv/BttvLiveUpdates.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" +#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp" +#include "providers/liveupdates/BasicPubSubManager.hpp" +#include "util/QStringHash.hpp" + +#include + +#include + +namespace chatterino { + +class BttvLiveUpdates : public BasicPubSubManager +{ + template + using Signal = + pajlada::Signals::Signal; // type-id is vector> + +public: + BttvLiveUpdates(QString host); + + struct { + Signal emoteAdded; + Signal emoteUpdated; + Signal emoteRemoved; + } signals_; // NOLINT(readability-identifier-naming) + + /** + * Joins a Twitch channel by its id (without any prefix like 'twitch:') + * if it's not already joined. + * + * @param channelID the Twitch channel-id of the broadcaster. + * @param userName the Twitch username of the current user. + */ + void joinChannel(const QString &channelID, const QString &userName); + + /** + * Parts a twitch channel by its id (without any prefix like 'twitch:') + * if it's joined. + * + * @param id the Twitch channel-id of the broadcaster. + */ + void partChannel(const QString &id); + +protected: + void onMessage( + websocketpp::connection_hdl hdl, + BasicPubSubManager::WebsocketMessagePtr msg) + override; + +private: + // Contains all joined Twitch channel-ids + std::unordered_set joinedChannels_; +}; + +} // namespace chatterino diff --git a/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.cpp b/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.cpp new file mode 100644 index 000000000..a0a5405eb --- /dev/null +++ b/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.cpp @@ -0,0 +1,52 @@ +#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" + +namespace { + +bool tryParseChannelId(QString &channelId) +{ + if (!channelId.startsWith("twitch:")) + { + return false; + } + + channelId.remove(0, 7); // "twitch:" + return true; +} + +} // namespace + +namespace chatterino { + +BttvLiveUpdateEmoteUpdateAddMessage::BttvLiveUpdateEmoteUpdateAddMessage( + const QJsonObject &json) + : channelID(json["channel"].toString()) + , jsonEmote(json["emote"].toObject()) + , emoteName(this->jsonEmote["code"].toString()) + , emoteID(this->jsonEmote["id"].toString()) + , badChannelID_(!tryParseChannelId(this->channelID)) +{ +} + +bool BttvLiveUpdateEmoteUpdateAddMessage::validate() const +{ + // We don't need to check for jsonEmote["code"]/["id"], + // because these are this->emoteID and this->emoteName. + return !this->badChannelID_ && !this->channelID.isEmpty() && + !this->emoteID.isEmpty() && !this->emoteName.isEmpty(); +} + +BttvLiveUpdateEmoteRemoveMessage::BttvLiveUpdateEmoteRemoveMessage( + const QJsonObject &json) + : channelID(json["channel"].toString()) + , emoteID(json["emoteId"].toString()) + , badChannelID_(!tryParseChannelId(this->channelID)) +{ +} + +bool BttvLiveUpdateEmoteRemoveMessage::validate() const +{ + return !this->badChannelID_ && !this->emoteID.isEmpty() && + !this->channelID.isEmpty(); +} + +} // namespace chatterino diff --git a/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp b/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp new file mode 100644 index 000000000..afa25ff71 --- /dev/null +++ b/src/providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace chatterino { + +struct BttvLiveUpdateEmoteUpdateAddMessage { + BttvLiveUpdateEmoteUpdateAddMessage(const QJsonObject &json); + + QString channelID; + + QJsonObject jsonEmote; + QString emoteName; + QString emoteID; + + bool validate() const; + +private: + // true if the channel id is malformed + // (e.g. doesn't start with "twitch:") + bool badChannelID_; +}; + +struct BttvLiveUpdateEmoteRemoveMessage { + BttvLiveUpdateEmoteRemoveMessage(const QJsonObject &json); + + QString channelID; + QString emoteID; + + bool validate() const; + +private: + // true if the channel id is malformed + // (e.g. doesn't start with "twitch:") + bool badChannelID_; +}; + +} // namespace chatterino diff --git a/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp new file mode 100644 index 000000000..cce726357 --- /dev/null +++ b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp @@ -0,0 +1,107 @@ +#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp" + +#include + +namespace chatterino { + +QByteArray BttvLiveUpdateSubscription::encodeSubscribe() const +{ + return QJsonDocument(std::visit( + [](const auto &d) { + return d.encode(true); + }, + this->data)) + .toJson(); +} + +QByteArray BttvLiveUpdateSubscription::encodeUnsubscribe() const +{ + return QJsonDocument(std::visit( + [](const auto &d) { + return d.encode(false); + }, + this->data)) + .toJson(); +} + +QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscription &subscription) +{ + std::visit( + [&](const auto &data) { + dbg << data; + }, + subscription.data); + return dbg; +} + +QJsonObject BttvLiveUpdateSubscriptionChannel::encode(bool isSubscribe) const +{ + QJsonObject root; + if (isSubscribe) + { + root["name"] = "join_channel"; + } + else + { + root["name"] = "part_channel"; + } + + QJsonObject data; + data["name"] = QString("twitch:%1").arg(this->twitchID); + + root["data"] = data; + return root; +} + +bool BttvLiveUpdateSubscriptionChannel::operator==( + const BttvLiveUpdateSubscriptionChannel &rhs) const +{ + return this->twitchID == rhs.twitchID; +} + +bool BttvLiveUpdateSubscriptionChannel::operator!=( + const BttvLiveUpdateSubscriptionChannel &rhs) const +{ + return !(*this == rhs); +} + +QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscriptionChannel &data) +{ + dbg << "BttvLiveUpdateSubscriptionChannel{ twitchID:" << data.twitchID + << '}'; + return dbg; +} + +QJsonObject BttvLiveUpdateBroadcastMe::encode(bool /*isSubscribe*/) const +{ + QJsonObject root; + root["name"] = "broadcast_me"; + + QJsonObject data; + data["name"] = this->userName; + data["channel"] = QString("twitch:%1").arg(this->twitchID); + + root["data"] = data; + return root; +} + +bool BttvLiveUpdateBroadcastMe::operator==( + const BttvLiveUpdateBroadcastMe &rhs) const +{ + return this->twitchID == rhs.twitchID && this->userName == rhs.userName; +} + +bool BttvLiveUpdateBroadcastMe::operator!=( + const BttvLiveUpdateBroadcastMe &rhs) const +{ + return !(*this == rhs); +} + +QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateBroadcastMe &data) +{ + dbg << "BttvLiveUpdateBroadcastMe{ twitchID:" << data.twitchID + << "userName:" << data.userName << '}'; + return dbg; +} + +} // namespace chatterino diff --git a/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp new file mode 100644 index 000000000..45dff37aa --- /dev/null +++ b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace chatterino { + +struct BttvLiveUpdateSubscriptionChannel { + QString twitchID; + + QJsonObject encode(bool isSubscribe) const; + bool operator==(const BttvLiveUpdateSubscriptionChannel &rhs) const; + bool operator!=(const BttvLiveUpdateSubscriptionChannel &rhs) const; + friend QDebug &operator<<(QDebug &dbg, + const BttvLiveUpdateSubscriptionChannel &data); +}; + +struct BttvLiveUpdateBroadcastMe { + QString twitchID; + QString userName; + + QJsonObject encode(bool isSubscribe) const; + bool operator==(const BttvLiveUpdateBroadcastMe &rhs) const; + bool operator!=(const BttvLiveUpdateBroadcastMe &rhs) const; + friend QDebug &operator<<(QDebug &dbg, + const BttvLiveUpdateBroadcastMe &data); +}; + +using BttvLiveUpdateSubscriptionData = + std::variant; + +struct BttvLiveUpdateSubscription { + BttvLiveUpdateSubscriptionData data; + + QByteArray encodeSubscribe() const; + QByteArray encodeUnsubscribe() const; + + bool operator==(const BttvLiveUpdateSubscription &rhs) const + { + return this->data == rhs.data; + } + bool operator!=(const BttvLiveUpdateSubscription &rhs) const + { + return !(*this == rhs); + } + + friend QDebug &operator<<(QDebug &dbg, + const BttvLiveUpdateSubscription &subscription); +}; + +} // namespace chatterino + +namespace std { + +template <> +struct hash { + size_t operator()( + const chatterino::BttvLiveUpdateSubscriptionChannel &data) const + { + return qHash(data.twitchID); + } +}; + +template <> +struct hash { + size_t operator()(const chatterino::BttvLiveUpdateBroadcastMe &data) const + { + size_t seed = 0; + boost::hash_combine(seed, qHash(data.twitchID)); + boost::hash_combine(seed, qHash(data.userName)); + return seed; + } +}; + +template <> +struct hash { + size_t operator()(const chatterino::BttvLiveUpdateSubscription &sub) const + { + return std::hash{}( + sub.data); + } +}; + +} // namespace std diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 2676aacd5..5e3892ed6 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -15,6 +15,8 @@ #include "messages/MessageElement.hpp" #include "messages/MessageThread.hpp" #include "providers/bttv/BttvEmotes.hpp" +#include "providers/bttv/BttvLiveUpdates.hpp" +#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" #include "providers/RecentMessagesApi.hpp" #include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp" #include "providers/seventv/SeventvEmotes.hpp" @@ -103,6 +105,7 @@ TwitchChannel::TwitchChannel(const QString &name) this->refreshFFZChannelEmotes(false); this->refreshBTTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false); + this->joinBttvChannel(); }); this->connected.connect([this]() { @@ -121,6 +124,11 @@ TwitchChannel::TwitchChannel(const QString &name) this->destroyed.connect([this]() { getApp()->twitch->dropSeventvChannel(this->seventvUserID_, this->seventvEmoteSetID_); + + if (getApp()->twitch->bttvLiveUpdates) + { + getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId()); + } }); this->messageRemovedFromStart.connect([this](MessagePtr &msg) { @@ -622,6 +630,66 @@ const QString &TwitchChannel::seventvEmoteSetID() const return this->seventvEmoteSetID_; } +void TwitchChannel::joinBttvChannel() const +{ + if (getApp()->twitch->bttvLiveUpdates) + { + const auto currentAccount = getApp()->accounts->twitch.getCurrent(); + QString userName; + if (currentAccount && !currentAccount->isAnon()) + { + userName = currentAccount->getUserName(); + } + getApp()->twitch->bttvLiveUpdates->joinChannel(this->roomId(), + userName); + } +} + +void TwitchChannel::addBttvEmote( + const BttvLiveUpdateEmoteUpdateAddMessage &message) +{ + auto emote = BttvEmotes::addEmote(this->getDisplayName(), this->bttvEmotes_, + message); + + this->addOrReplaceLiveUpdatesAddRemove(true, "BTTV", QString() /*actor*/, + emote->name.string); +} + +void TwitchChannel::updateBttvEmote( + const BttvLiveUpdateEmoteUpdateAddMessage &message) +{ + auto updated = BttvEmotes::updateEmote(this->getDisplayName(), + this->bttvEmotes_, message); + if (!updated) + { + return; + } + + const auto [oldEmote, newEmote] = *updated; + if (oldEmote->name == newEmote->name) + { + return; // only the creator changed + } + + auto builder = MessageBuilder(liveUpdatesUpdateEmoteMessage, "BTTV", + QString() /* actor */, newEmote->name.string, + oldEmote->name.string); + this->addMessage(builder.release()); +} + +void TwitchChannel::removeBttvEmote( + const BttvLiveUpdateEmoteRemoveMessage &message) +{ + auto removed = BttvEmotes::removeEmote(this->bttvEmotes_, message); + if (!removed) + { + return; + } + + this->addOrReplaceLiveUpdatesAddRemove(false, "BTTV", QString() /*actor*/, + removed.get()->name.string); +} + void TwitchChannel::addSeventvEmote( const SeventvEventAPIEmoteAddDispatch &dispatch) { diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 3596f570f..7435f06d8 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -47,6 +47,8 @@ class EmoteMap; class TwitchBadges; class FfzEmotes; class BttvEmotes; +struct BttvLiveUpdateEmoteUpdateAddMessage; +struct BttvLiveUpdateEmoteRemoveMessage; class SeventvEmotes; struct SeventvEventAPIEmoteAddDispatch; struct SeventvEventAPIEmoteUpdateDispatch; @@ -139,6 +141,13 @@ public: const QString &seventvUserID() const; const QString &seventvEmoteSetID() const; + /** Adds a BTTV channel emote to this channel. */ + void addBttvEmote(const BttvLiveUpdateEmoteUpdateAddMessage &message); + /** Updates a BTTV channel emote in this channel. */ + void updateBttvEmote(const BttvLiveUpdateEmoteUpdateAddMessage &message); + /** Removes a BTTV channel emote from this channel. */ + void removeBttvEmote(const BttvLiveUpdateEmoteRemoveMessage &message); + /** Adds a 7TV channel emote to this channel. */ void addSeventvEmote(const SeventvEventAPIEmoteAddDispatch &dispatch); /** Updates a 7TV channel emote's name in this channel */ @@ -206,6 +215,8 @@ private: void fetchDisplayName(); void cleanUpReplyThreads(); void showLoginMessage(); + /** Joins (subscribes to) a Twitch channel for updates on BTTV. */ + void joinBttvChannel() const; void setLive(bool newLiveStatus); void setMod(bool value); diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 07c924dbc..70d197f69 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -6,6 +6,7 @@ #include "controllers/accounts/AccountController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/bttv/BttvLiveUpdates.hpp" #include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp" #include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/api/Helix.hpp" @@ -30,6 +31,7 @@ using namespace std::chrono_literals; namespace { +const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws"; const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3"; } // namespace @@ -45,6 +47,14 @@ TwitchIrcServer::TwitchIrcServer() this->initializeIrc(); this->pubsub = new PubSub(TWITCH_PUBSUB_URL); + + if (getSettings()->enableBTTVLiveUpdates && + getSettings()->enableBTTVChannelEmotes) + { + this->bttvLiveUpdates = + std::make_unique(BTTV_LIVE_UPDATES_URL); + } + if (getSettings()->enableSevenTVEventAPI && getSettings()->enableSevenTVChannelEmotes) { diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index e93fcc4fc..9a9a22800 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -20,6 +20,7 @@ class Settings; class Paths; class PubSub; class TwitchChannel; +class BttvLiveUpdates; class SeventvEventAPI; class TwitchIrcServer final : public AbstractIrcServer, public Singleton @@ -66,6 +67,7 @@ public: IndirectChannel watchingChannel; PubSub *pubsub; + std::unique_ptr bttvLiveUpdates; std::unique_ptr seventvEventAPI; const BttvEmotes &getBttvEmotes() const; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 992e387b4..cd10ce0ee 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -225,6 +225,7 @@ public: BoolSetting enableBTTVGlobalEmotes = {"/emotes/bttv/global", true}; BoolSetting enableBTTVChannelEmotes = {"/emotes/bttv/channel", true}; + BoolSetting enableBTTVLiveUpdates = {"/emotes/bttv/liveupdates", true}; BoolSetting enableFFZGlobalEmotes = {"/emotes/ffz/global", true}; BoolSetting enableFFZChannelEmotes = {"/emotes/ffz/channel", true}; BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 3de5a77af..4f6523aa5 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -432,6 +432,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) s.emojiSet); layout.addCheckbox("Show BTTV global emotes", s.enableBTTVGlobalEmotes); layout.addCheckbox("Show BTTV channel emotes", s.enableBTTVChannelEmotes); + layout.addCheckbox("Enable BTTV live emote updates (requires restart)", + s.enableBTTVLiveUpdates); layout.addCheckbox("Show FFZ global emotes", s.enableFFZGlobalEmotes); layout.addCheckbox("Show FFZ channel emotes", s.enableFFZChannelEmotes); layout.addCheckbox("Show 7TV global emotes", s.enableSevenTVGlobalEmotes); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2ba34a685..57a5fcb17 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp ${CMAKE_CURRENT_LIST_DIR}/src/BasicPubSub.cpp ${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/BttvLiveUpdates.cpp # Add your new file above this line! ) diff --git a/tests/src/BttvLiveUpdates.cpp b/tests/src/BttvLiveUpdates.cpp new file mode 100644 index 000000000..6c12388d1 --- /dev/null +++ b/tests/src/BttvLiveUpdates.cpp @@ -0,0 +1,59 @@ +#include "providers/bttv/BttvLiveUpdates.hpp" + +#include +#include +#include + +using namespace chatterino; +using namespace std::chrono_literals; + +const QString TARGET_USER_ID = "1234567"; +const QString TARGET_USER_NAME = "Alien"; + +TEST(BttvLiveUpdates, AllEvents) +{ + const QString host("wss://127.0.0.1:9050/liveupdates/bttv/all-events"); + auto *liveUpdates = new BttvLiveUpdates(host); + liveUpdates->start(); + + boost::optional addMessage; + boost::optional updateMessage; + boost::optional removeMessage; + + liveUpdates->signals_.emoteAdded.connect([&](const auto &m) { + addMessage = m; + }); + liveUpdates->signals_.emoteUpdated.connect([&](const auto &m) { + updateMessage = m; + }); + liveUpdates->signals_.emoteRemoved.connect([&](const auto &m) { + removeMessage = m; + }); + + std::this_thread::sleep_for(50ms); + liveUpdates->joinChannel(TARGET_USER_ID, TARGET_USER_NAME); + std::this_thread::sleep_for(500ms); + + ASSERT_EQ(liveUpdates->diag.connectionsOpened, 1); + ASSERT_EQ(liveUpdates->diag.connectionsClosed, 0); + ASSERT_EQ(liveUpdates->diag.connectionsFailed, 0); + + auto add = *addMessage; + ASSERT_EQ(add.channelID, TARGET_USER_ID); + ASSERT_EQ(add.emoteName, QString("PepePls")); + ASSERT_EQ(add.emoteID, QString("55898e122612142e6aaa935b")); + + auto update = *updateMessage; + ASSERT_EQ(update.channelID, TARGET_USER_ID); + ASSERT_EQ(update.emoteName, QString("PepePls")); + ASSERT_EQ(update.emoteID, QString("55898e122612142e6aaa935b")); + + auto rem = *removeMessage; + ASSERT_EQ(rem.channelID, TARGET_USER_ID); + ASSERT_EQ(rem.emoteID, QString("55898e122612142e6aaa935b")); + + liveUpdates->stop(); + ASSERT_EQ(liveUpdates->diag.connectionsOpened, 1); + ASSERT_EQ(liveUpdates->diag.connectionsClosed, 1); + ASSERT_EQ(liveUpdates->diag.connectionsFailed, 0); +}