diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd8eb8e37e..4aa0a7ccdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Minor: Reply context now censors blocked users. (#4502) - Minor: Added system message for empty mod list. (#4546) - Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) +- Minor: Migrated badges to Helix API. (#4537) - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index e959f66cba7..daf782f2918 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -6,6 +6,7 @@ #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "providers/twitch/api/Helix.hpp" #include "util/DisplayBadge.hpp" #include @@ -29,25 +30,49 @@ void TwitchBadges::loadTwitchBadges() { assert(this->loaded_ == false); - QUrl url("https://badges.twitch.tv/v1/badges/global/display"); + getHelix()->getGlobalBadges( + [this](auto globalBadges) { + auto badgeSets = this->badgeSets_.access(); - QUrlQuery urlQuery; - urlQuery.addQueryItem("language", "en"); - url.setQuery(urlQuery); - - NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { - auto root = result.parseJson(); - - this->parseTwitchBadges(root); + for (const auto &badgeSet : globalBadges.badgeSets) + { + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) + { + const auto &emote = Emote{ + EmoteName{}, + ImageSet{ + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), + }, + Tooltip{version.title}, + version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); + } + } this->loaded(); - return Success; - }) - .onError([this](auto res) { - qCWarning(chatterinoTwitch) - << "Error loading Twitch Badges from the badges API:" - << res.status() << " - falling back to backup"; + }, + [this](auto error, auto message) { + QString errorMessage("Failed to load global badges - "); + + switch (error) + { + case HelixGetGlobalBadgesError::Forwarded: { + errorMessage += message; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetGlobalBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + qCWarning(chatterinoTwitch) << errorMessage; QFile file(":/twitch-badges.json"); if (!file.open(QFile::ReadOnly)) { @@ -64,8 +89,7 @@ void TwitchBadges::loadTwitchBadges() this->parseTwitchBadges(doc.object()); this->loaded(); - }) - .execute(); + }); } void TwitchBadges::parseTwitchBadges(QJsonObject root) @@ -93,7 +117,8 @@ void TwitchBadges::parseTwitchBadges(QJsonObject root) {versionObj.value("image_url_4x").toString()}, .25), }, Tooltip{versionObj.value("title").toString()}, - Url{versionObj.value("click_url").toString()}}; + Url{versionObj.value("click_url").toString()}, + }; // "title" // "clickAction" diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index a87e2246d37..42b2494db52 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -50,9 +50,6 @@ class TwitchBadges TwitchBadges(); void loadTwitchBadges(); - /** - * @brief Accepts a JSON blob from https://badges.twitch.tv/v1/badges/global/display and updates our badges with it - **/ void parseTwitchBadges(QJsonObject root); void loaded(); void loadEmoteImage(const QString &name, ImagePtr image, diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 96c3217cbff..4a81da24387 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1282,50 +1282,71 @@ void TwitchChannel::cleanUpReplyThreads() void TwitchChannel::refreshBadges() { - auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" + - this->roomId() + "/display?language=en"}; - NetworkRequest(url.string) + if (this->roomId().isEmpty()) + { + return; + } - .onSuccess([this, - weak = weakOf(this)](auto result) -> Outcome { + getHelix()->getChannelBadges( + this->roomId(), + // successCallback + [this, weak = weakOf(this)](auto channelBadges) { auto shared = weak.lock(); if (!shared) - return Failure; + { + // The channel has been closed inbetween us making the request and the request finishing + return; + } auto badgeSets = this->badgeSets_.access(); - auto jsonRoot = result.parseJson(); - - auto _ = jsonRoot["badge_sets"].toObject(); - for (auto jsonBadgeSet = _.begin(); jsonBadgeSet != _.end(); - jsonBadgeSet++) + for (const auto &badgeSet : channelBadges.badgeSets) { - auto &versions = (*badgeSets)[jsonBadgeSet.key()]; - - auto _set = jsonBadgeSet->toObject()["versions"].toObject(); - for (auto jsonVersion_ = _set.begin(); - jsonVersion_ != _set.end(); jsonVersion_++) + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) { - auto jsonVersion = jsonVersion_->toObject(); - auto emote = std::make_shared(Emote{ + auto emote = Emote{ EmoteName{}, ImageSet{ - Image::fromUrl( - {jsonVersion["image_url_1x"].toString()}, 1), - Image::fromUrl( - {jsonVersion["image_url_2x"].toString()}, .5), - Image::fromUrl( - {jsonVersion["image_url_4x"].toString()}, .25)}, - Tooltip{jsonVersion["description"].toString()}, - Url{jsonVersion["clickURL"].toString()}}); - - versions.emplace(jsonVersion_.key(), emote); - }; + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), + }, + Tooltip{version.title}, + version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); + } + } + }, + // failureCallback + [this, weak = weakOf(this)](auto error, auto message) { + auto shared = weak.lock(); + if (!shared) + { + // The channel has been closed inbetween us making the request and the request finishing + return; } - return Success; - }) - .execute(); + QString errorMessage("Failed to load channel badges - "); + + switch (error) + { + case HelixGetChannelBadgesError::Forwarded: { + errorMessage += message; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetChannelBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + this->addMessage(makeSystemMessage(errorMessage)); + }); } void TwitchChannel::refreshCheerEmotes() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 5df03372e09..71c19175a24 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2468,6 +2468,98 @@ void Helix::startCommercial( .execute(); } +// Twitch global badges +// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges +void Helix::getGlobalBadges( + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetGlobalBadgesError; + + this->makeRequest("chat/badges/global", QUrlQuery()) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting global badges was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixGlobalBadges(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix global badges, unhandled error data:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + +// Badges for the `broadcasterID` channel +// https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges +void Helix::getChannelBadges( + QString broadcasterID, ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetChannelBadgesError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + this->makeRequest("chat/badges", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting badges was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixChannelBadges(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix channel badges, unhandled error data:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) { assert(!url.startsWith("/")); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 42242fc8e06..f3286a72903 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -384,6 +384,55 @@ struct HelixModerators { } }; +struct HelixBadgeVersion { + QString id; + Url imageURL1x; + Url imageURL2x; + Url imageURL4x; + QString title; + Url clickURL; + + explicit HelixBadgeVersion(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , imageURL1x(Url{jsonObject.value("image_url_1x").toString()}) + , imageURL2x(Url{jsonObject.value("image_url_2x").toString()}) + , imageURL4x(Url{jsonObject.value("image_url_4x").toString()}) + , title(jsonObject.value("title").toString()) + , clickURL(Url{jsonObject.value("click_url").toString()}) + { + } +}; + +struct HelixBadgeSet { + QString setID; + std::vector versions; + + explicit HelixBadgeSet(const QJsonObject &json) + : setID(json.value("set_id").toString()) + { + const auto jsonVersions = json.value("versions").toArray(); + for (const auto &version : jsonVersions) + { + versions.emplace_back(version.toObject()); + } + } +}; + +struct HelixGlobalBadges { + std::vector badgeSets; + + explicit HelixGlobalBadges(const QJsonObject &jsonObject) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &set : data) + { + this->badgeSets.emplace_back(set.toObject()); + } + } +}; + +using HelixChannelBadges = HelixGlobalBadges; + enum class HelixAnnouncementColor { Blue, Green, @@ -616,6 +665,15 @@ enum class HelixStartCommercialError { Forwarded, }; +enum class HelixGetGlobalBadgesError { + Unknown, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + +using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; + class IHelix { public: @@ -899,6 +957,21 @@ class IHelix FailureCallback failureCallback) = 0; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + virtual void getGlobalBadges( + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + virtual void getChannelBadges( + QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1184,6 +1257,19 @@ class Helix final : public IHelix FailureCallback failureCallback) final; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + void getGlobalBadges(ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + void getChannelBadges(QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index b889761840c..b05be517e33 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -136,6 +136,22 @@ Used in: - `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000` +### Get Global Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + +Used in: + +- `providers/twitch/TwitchBadges.cpp` to load global badges + +### Get Channel Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + +Used in: + +- `providers/twitch/TwitchChannel.cpp` to load channel badges + ### Get Emote Sets URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index a3f37b26b1a..181ca57a3b8 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -225,6 +225,21 @@ class MockHelix : public IHelix HelixFailureCallback failureCallback), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getGlobalBadges, + (ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, getChannelBadges, + (QString broadcasterID, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD(void, updateUserChatColor, (QString userID, QString color,