From 495f3ed4a982e6d70da19d5ba651d636cccd4abf Mon Sep 17 00:00:00 2001 From: Colton Clemmer Date: Tue, 1 Nov 2022 17:18:57 -0500 Subject: [PATCH] Migrate /chatters commands to use Helix api (#4088) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 64 ++++++++-- src/providers/twitch/TwitchChannel.cpp | 71 ++++------- src/providers/twitch/api/Helix.cpp | 114 ++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 57 +++++++++ tests/src/HighlightController.cpp | 9 ++ 6 files changed, 257 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ae71c16..4182ce03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) +- Minor: Migrated /chatters to Helix API. (#4088) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 8a403810b..1f8308aa9 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -878,23 +878,65 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); - this->registerCommand( - "/chatters", [](const auto & /*words*/, auto channel) { - auto twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/chatters", [](const auto &words, auto channel) { + auto formatError = [](HelixGetChattersError error, QString message) { + using Error = HelixGetChattersError; - if (twitchChannel == nullptr) + QString errorMessage = QString("Failed to get chatter count: "); + + switch (error) { - channel->addMessage(makeSystemMessage( - "The /chatters command only works in Twitch Channels")); - return ""; + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must have moderator permissions to " + "use this command."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; } + return errorMessage; + }; + auto twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { channel->addMessage(makeSystemMessage( - QString("Chatter count: %1") - .arg(localizeNumbers(twitchChannel->chatterCount())))); - + "The /chatters command only works in Twitch Channels")); return ""; - }); + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 1, + [channel](auto result) { + channel->addMessage( + makeSystemMessage(QString("Chatter count: %1") + .arg(localizeNumbers(result.total)))); + }, + [channel, formatError](auto error, auto message) { + auto errorMessage = formatError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { if (const auto type = channel->getType(); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 81783e5f7..e0cd7cf58 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -49,29 +49,8 @@ namespace { const QString LOGIN_PROMPT_TEXT("Click here to add your account again."); const Link ACCOUNTS_LINK(Link::OpenAccountsPage, QString()); - std::pair> parseChatters( - const QJsonObject &jsonRoot) - { - static QStringList categories = {"broadcaster", "vips", "moderators", - "staff", "admins", "global_mods", - "viewers"}; - - auto usernames = std::unordered_set(); - - // parse json - QJsonObject jsonCategories = jsonRoot.value("chatters").toObject(); - - for (const auto &category : categories) - { - for (auto jsonCategory : jsonCategories.value(category).toArray()) - { - usernames.insert(jsonCategory.toString()); - } - } - - return {Success, std::move(usernames)}; - } - + // Maximum number of chatters to fetch when refreshing chatters + constexpr auto MAX_CHATTERS_TO_FETCH = 5000; } // namespace TwitchChannel::TwitchChannel(const QString &name) @@ -136,9 +115,11 @@ TwitchChannel::TwitchChannel(const QString &name) }); // timers + QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] { this->refreshChatters(); }); + this->chattersListTimer_.start(5 * 60 * 1000); QObject::connect(&this->threadClearTimer_, &QTimer::timeout, [=] { @@ -905,6 +886,12 @@ void TwitchChannel::refreshPubSub() void TwitchChannel::refreshChatters() { + // helix endpoint only works for mods + if (!this->hasModRights()) + { + return; + } + // setting? const auto streamStatus = this->accessStreamStatus(); const auto viewerCount = static_cast(streamStatus->viewerCount); @@ -917,31 +904,19 @@ void TwitchChannel::refreshChatters() } } - // get viewer list - NetworkRequest("https://tmi.twitch.tv/group/user/" + this->getName() + - "/chatters") - - .onSuccess( - [this, weak = weakOf(this)](auto result) -> Outcome { - // channel still exists? - auto shared = weak.lock(); - if (!shared) - { - return Failure; - } - - auto data = result.parseJson(); - this->chatterCount_ = data.value("chatter_count").toInt(); - - auto pair = parseChatters(std::move(data)); - if (pair.first) - { - this->updateOnlineChatters(pair.second); - } - - return pair.first; - }) - .execute(); + // Get chatter list via helix api + getHelix()->getChatters( + this->roomId(), getApp()->accounts->twitch.getCurrent()->getUserId(), + MAX_CHATTERS_TO_FETCH, + [this, weak = weakOf(this)](auto result) { + if (auto shared = weak.lock()) + { + this->updateOnlineChatters(result.chatters); + this->chatterCount_ = result.total; + } + }, + // Refresh chatters should only be used when failing silently is an option + [](auto error, auto message) {}); } void TwitchChannel::fetchDisplayName() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index b30229282..7ed8794bd 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1782,6 +1782,83 @@ void Helix::updateChatSettings( .execute(); } +// https://dev.twitch.tv/docs/api/reference#get-chatters +void Helix::fetchChatters( + QString broadcasterID, QString moderatorID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetChattersError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + urlQuery.addQueryItem("first", QString::number(first)); + + if (!after.isEmpty()) + { + urlQuery.addQueryItem("after", after); + } + + this->makeRequest("chat/chatters", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting chatters was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixChatters(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains("OAuth token")) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error data:" << result.status() + << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + // Ban/timeout a user // https://dev.twitch.tv/docs/api/reference#ban-user void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, @@ -1991,6 +2068,43 @@ void Helix::sendWhisper( .execute(); } +// https://dev.twitch.tv/docs/api/reference#get-chatters +void Helix::getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + static const auto NUM_CHATTERS_TO_FETCH = 1000; + + auto finalChatters = std::make_shared(); + + ResultCallback fetchSuccess; + + fetchSuccess = [this, broadcasterID, moderatorID, maxChattersToFetch, + finalChatters, &fetchSuccess, successCallback, + failureCallback](auto chatters) { + qCDebug(chatterinoTwitch) + << "Fetched" << chatters.chatters.size() << "chatters"; + finalChatters->chatters.merge(chatters.chatters); + finalChatters->total = chatters.total; + + if (chatters.cursor.isEmpty() || + finalChatters->chatters.size() >= maxChattersToFetch) + { + // Done paginating + successCallback(*finalChatters); + return; + } + + this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, + chatters.cursor, fetchSuccess, failureCallback); + }; + + // Initiate the recursive calls + this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, "", + fetchSuccess, failureCallback); +} + // List the VIPs of a channel // https://dev.twitch.tv/docs/api/reference#get-vips void Helix::getChannelVIPs( diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 0e491f3c4..3ec4157f4 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -3,6 +3,7 @@ #include "common/Aliases.hpp" #include "common/NetworkRequest.hpp" #include "providers/twitch/TwitchEmotes.hpp" +#include "util/QStringHash.hpp" #include #include @@ -12,6 +13,7 @@ #include #include +#include #include namespace chatterino { @@ -342,6 +344,29 @@ struct HelixVip { } }; +struct HelixChatters { + std::unordered_set chatters; + int total; + QString cursor; + + HelixChatters() = default; + + explicit HelixChatters(const QJsonObject &jsonObject) + : total(jsonObject.value("total").toInt()) + , cursor(jsonObject.value("pagination") + .toObject() + .value("cursor") + .toString()) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &chatter : data) + { + auto userLogin = chatter.toObject().value("user_login").toString(); + this->chatters.insert(userLogin); + } + } +}; + // TODO(jammehcow): when implementing mod list, just alias HelixVip to HelixMod // as they share the same model. // Alternatively, rename base struct to HelixUser or something and alias both @@ -519,6 +544,15 @@ enum class HelixWhisperError { // /w Forwarded, }; // /w +enum class HelixGetChattersError { + Unknown, + UserMissingScope, + UserNotAuthorized, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixListVIPsError { // /vips Unknown, UserMissingScope, @@ -784,6 +818,14 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Get Chatters from the `broadcasterID` channel + // This will follow the returned cursor and return up to `maxChattersToFetch` chatters + // https://dev.twitch.tv/docs/api/reference#get-chatters + virtual void getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips virtual void getChannelVIPs( QString broadcasterID, @@ -1045,6 +1087,14 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Get Chatters from the `broadcasterID` channel + // This will follow the returned cursor and return up to `maxChattersToFetch` chatters + // https://dev.twitch.tv/docs/api/reference#get-chatters + void getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-vips void getChannelVIPs( QString broadcasterID, @@ -1063,6 +1113,13 @@ protected: FailureCallback failureCallback) final; + // Get chatters list - This method is what actually runs the API request + // https://dev.twitch.tv/docs/api/reference#get-chatters + void fetchChatters( + QString broadcasterID, QString moderatorID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback); + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 982fc2800..36a49df74 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -360,6 +360,15 @@ public: (FailureCallback failureCallback)), (override)); // /w + // getChatters + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getChatters, + (QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // getChatters + // /vips // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD(