Migrate Twitch badges to Helix (#4537)

Co-authored-by: iProdigy <8106344+iProdigy@users.noreply.github.com>
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Zonian 2023-04-16 04:58:45 -05:00 committed by GitHub
parent 594477d8b6
commit d6ef48d4ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 307 additions and 54 deletions

View file

@ -12,6 +12,7 @@
- Minor: Reply context now censors blocked users. (#4502) - Minor: Reply context now censors blocked users. (#4502)
- Minor: Added system message for empty mod list. (#4546) - Minor: Added system message for empty mod list. (#4546)
- Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) - 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 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 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) - Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460)

View file

@ -6,6 +6,7 @@
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "messages/Emote.hpp" #include "messages/Emote.hpp"
#include "messages/Image.hpp" #include "messages/Image.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "util/DisplayBadge.hpp" #include "util/DisplayBadge.hpp"
#include <QBuffer> #include <QBuffer>
@ -29,25 +30,49 @@ void TwitchBadges::loadTwitchBadges()
{ {
assert(this->loaded_ == false); 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; for (const auto &badgeSet : globalBadges.badgeSets)
urlQuery.addQueryItem("language", "en"); {
url.setQuery(urlQuery); const auto &setID = badgeSet.setID;
for (const auto &version : badgeSet.versions)
NetworkRequest(url) {
.onSuccess([this](auto result) -> Outcome { const auto &emote = Emote{
auto root = result.parseJson(); EmoteName{},
ImageSet{
this->parseTwitchBadges(root); 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>(emote);
}
}
this->loaded(); this->loaded();
return Success; },
}) [this](auto error, auto message) {
.onError([this](auto res) { QString errorMessage("Failed to load global badges - ");
qCWarning(chatterinoTwitch)
<< "Error loading Twitch Badges from the badges API:" switch (error)
<< res.status() << " - falling back to backup"; {
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"); QFile file(":/twitch-badges.json");
if (!file.open(QFile::ReadOnly)) if (!file.open(QFile::ReadOnly))
{ {
@ -64,8 +89,7 @@ void TwitchBadges::loadTwitchBadges()
this->parseTwitchBadges(doc.object()); this->parseTwitchBadges(doc.object());
this->loaded(); this->loaded();
}) });
.execute();
} }
void TwitchBadges::parseTwitchBadges(QJsonObject root) void TwitchBadges::parseTwitchBadges(QJsonObject root)
@ -93,7 +117,8 @@ void TwitchBadges::parseTwitchBadges(QJsonObject root)
{versionObj.value("image_url_4x").toString()}, .25), {versionObj.value("image_url_4x").toString()}, .25),
}, },
Tooltip{versionObj.value("title").toString()}, Tooltip{versionObj.value("title").toString()},
Url{versionObj.value("click_url").toString()}}; Url{versionObj.value("click_url").toString()},
};
// "title" // "title"
// "clickAction" // "clickAction"

View file

@ -50,9 +50,6 @@ private:
TwitchBadges(); TwitchBadges();
void loadTwitchBadges(); 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 parseTwitchBadges(QJsonObject root);
void loaded(); void loaded();
void loadEmoteImage(const QString &name, ImagePtr image, void loadEmoteImage(const QString &name, ImagePtr image,

View file

@ -1282,50 +1282,71 @@ void TwitchChannel::cleanUpReplyThreads()
void TwitchChannel::refreshBadges() void TwitchChannel::refreshBadges()
{ {
auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" + if (this->roomId().isEmpty())
this->roomId() + "/display?language=en"}; {
NetworkRequest(url.string) return;
}
.onSuccess([this, getHelix()->getChannelBadges(
weak = weakOf<Channel>(this)](auto result) -> Outcome { this->roomId(),
// successCallback
[this, weak = weakOf<Channel>(this)](auto channelBadges) {
auto shared = weak.lock(); auto shared = weak.lock();
if (!shared) 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 badgeSets = this->badgeSets_.access();
auto jsonRoot = result.parseJson(); for (const auto &badgeSet : channelBadges.badgeSets)
auto _ = jsonRoot["badge_sets"].toObject();
for (auto jsonBadgeSet = _.begin(); jsonBadgeSet != _.end();
jsonBadgeSet++)
{ {
auto &versions = (*badgeSets)[jsonBadgeSet.key()]; const auto &setID = badgeSet.setID;
for (const auto &version : badgeSet.versions)
auto _set = jsonBadgeSet->toObject()["versions"].toObject();
for (auto jsonVersion_ = _set.begin();
jsonVersion_ != _set.end(); jsonVersion_++)
{ {
auto jsonVersion = jsonVersion_->toObject(); auto emote = Emote{
auto emote = std::make_shared<Emote>(Emote{
EmoteName{}, EmoteName{},
ImageSet{ ImageSet{
Image::fromUrl( Image::fromUrl(version.imageURL1x, 1),
{jsonVersion["image_url_1x"].toString()}, 1), Image::fromUrl(version.imageURL2x, .5),
Image::fromUrl( Image::fromUrl(version.imageURL4x, .25),
{jsonVersion["image_url_2x"].toString()}, .5), },
Image::fromUrl( Tooltip{version.title},
{jsonVersion["image_url_4x"].toString()}, .25)}, version.clickURL,
Tooltip{jsonVersion["description"].toString()}, };
Url{jsonVersion["clickURL"].toString()}}); (*badgeSets)[setID][version.id] =
std::make_shared<Emote>(emote);
versions.emplace(jsonVersion_.key(), emote); }
}; }
},
// failureCallback
[this, weak = weakOf<Channel>(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; QString errorMessage("Failed to load channel badges - ");
})
.execute(); 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() void TwitchChannel::refreshCheerEmotes()

View file

@ -2468,6 +2468,98 @@ void Helix::startCommercial(
.execute(); .execute();
} }
// Twitch global badges
// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges
void Helix::getGlobalBadges(
ResultCallback<HelixGlobalBadges> successCallback,
FailureCallback<HelixGetGlobalBadgesError, QString> 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<HelixChannelBadges> successCallback,
FailureCallback<HelixGetChannelBadgesError, QString> 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) NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
{ {
assert(!url.startsWith("/")); assert(!url.startsWith("/"));

View file

@ -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<HelixBadgeVersion> 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<HelixBadgeSet> 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 { enum class HelixAnnouncementColor {
Blue, Blue,
Green, Green,
@ -616,6 +665,15 @@ enum class HelixStartCommercialError {
Forwarded, Forwarded,
}; };
enum class HelixGetGlobalBadgesError {
Unknown,
// The error message is forwarded directly from the Twitch API
Forwarded,
};
using HelixGetChannelBadgesError = HelixGetGlobalBadgesError;
class IHelix class IHelix
{ {
public: public:
@ -899,6 +957,21 @@ public:
FailureCallback<HelixStartCommercialError, QString> FailureCallback<HelixStartCommercialError, QString>
failureCallback) = 0; failureCallback) = 0;
// Get global Twitch badges
// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges
virtual void getGlobalBadges(
ResultCallback<HelixGlobalBadges> successCallback,
FailureCallback<HelixGetGlobalBadgesError, QString>
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<HelixChannelBadges> successCallback,
FailureCallback<HelixGetChannelBadgesError, QString>
failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0; virtual void update(QString clientId, QString oauthToken) = 0;
protected: protected:
@ -1184,6 +1257,19 @@ public:
FailureCallback<HelixStartCommercialError, QString> failureCallback) FailureCallback<HelixStartCommercialError, QString> failureCallback)
final; final;
// Get global Twitch badges
// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges
void getGlobalBadges(ResultCallback<HelixGlobalBadges> successCallback,
FailureCallback<HelixGetGlobalBadgesError, QString>
failureCallback) final;
// Get badges for the `broadcasterID` channel
// https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges
void getChannelBadges(QString broadcasterID,
ResultCallback<HelixChannelBadges> successCallback,
FailureCallback<HelixGetChannelBadgesError, QString>
failureCallback) final;
void update(QString clientId, QString oauthToken) final; void update(QString clientId, QString oauthToken) final;
static void initialize(); static void initialize();

View file

@ -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` - `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 ### Get Emote Sets
URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets

View file

@ -225,6 +225,21 @@ public:
HelixFailureCallback failureCallback), HelixFailureCallback failureCallback),
(override)); (override));
// The extra parenthesis around the failure callback is because its type contains a comma
MOCK_METHOD(
void, getGlobalBadges,
(ResultCallback<HelixGlobalBadges> successCallback,
(FailureCallback<HelixGetGlobalBadgesError, QString> failureCallback)),
(override));
// The extra parenthesis around the failure callback is because its type contains a comma
MOCK_METHOD(void, getChannelBadges,
(QString broadcasterID,
ResultCallback<HelixChannelBadges> successCallback,
(FailureCallback<HelixGetChannelBadgesError, QString>
failureCallback)),
(override));
// The extra parenthesis around the failure callback is because its type contains a comma // The extra parenthesis around the failure callback is because its type contains a comma
MOCK_METHOD(void, updateUserChatColor, MOCK_METHOD(void, updateUserChatColor,
(QString userID, QString color, (QString userID, QString color,