From ceecc7ef91cd9494f27104ef8ce445ade5d258f9 Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Mon, 10 Oct 2022 23:56:55 +1300 Subject: [PATCH] chore: migrate /vips command to Helix call (#4053) * feat(helix): create response model for VIP listing * feat(helix): stub out channel/vips request + handler * feat(helix): parse VIPs list from data and pass to callback * feat(helix): handle errors when getting VIP list then pass to callback * feat(command): add barebones handler for helix-based /vips * feat(command): provide better /vips output when user is not broadcaster * chore(format): bulk reformat with clang-format * chore(changelog): add entry for /vips Helix migration * fix(helix): use correct method when calling VIP list endpoint * fix(helix): use correct VIP list endpoint * chore(tidy): please clang-tidy by marking parameter as unused * feat(command): display unsorted VIP list returned from Helix API * feat(settings): clone raid timegate settings for /vips * feat(command): check /vips timegate setting before execution * feat(command): handle 0 VIPs from Helix response * feat(command): sort users alphabetically from Helix VIPs response * fix(command): highlight users in Helix /vips output to match IRC * fix(command): replace dynamic /vips error message with hardcoded string * chore(comment): remove TODO comment that was DONE * chore(format): bulk reformat using clang-format * fix(command): send 0 VIP message after creation * chore: apply suggestions from Felanbird * fix(helix): change mention of user ban to VIPs in VIP list error message * feat(helix): distinguish non-broadcaster auth error when getting VIPs * chore(command): move handling of non-broadcaster /vips usage to API response * chore(format): re-indent multiline string to get away from 80 char limit * reformat * fix tests Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 132 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 93 ++++++++++++ src/providers/twitch/api/Helix.hpp | 40 ++++++ src/singletons/Settings.hpp | 4 + src/widgets/settingspages/GeneralPage.cpp | 11 ++ tests/src/HighlightController.cpp | 9 ++ 7 files changed, 290 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0476967c..1566254f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ - Minor: Migrated /ban to Helix API. (#4049) - Minor: Migrated /timeout to Helix API. (#4049) - Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) +- Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - 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 b7680646..f8f1cb53 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -12,6 +12,7 @@ #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" @@ -2936,6 +2937,137 @@ void CommandController::initialize(Settings &, Paths &paths) return runWhisperCommand(words, channel); }); } + + auto formatVIPListError = [](HelixListVIPsError error, + const QString &message) -> QString { + using Error = HelixListVIPsError; + + QString errorMessage = QString("Failed to list VIPs - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserNotBroadcaster: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand( + "/vips", + [formatVIPListError](const QStringList &words, + auto channel) -> QString { + switch (getSettings()->helixTimegateVIPs.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /vips command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "Due to Twitch restrictions, " // + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the " + "Twitch website.")); + return ""; + } + + getHelix()->getChannelVIPs( + twitchChannel->roomId(), + [channel, twitchChannel](const std::vector &vipList) { + if (vipList.empty()) + { + channel->addMessage(makeSystemMessage( + "This channel does not have any VIPs.")); + return; + } + + auto messagePrefix = + QString("The VIPs of this channel are"); + auto entries = QStringList(); + + for (const auto &vip : vipList) + { + entries.append(vip.userName); + } + + entries.sort(Qt::CaseInsensitive); + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + messagePrefix, entries, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel, formatVIPListError](auto error, auto message) { + auto errorMessage = formatVIPListError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 7684b272..b3022928 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1991,6 +1991,99 @@ void Helix::sendWhisper( .execute(); } +// List the VIPs of a channel +// https://dev.twitch.tv/docs/api/reference#get-vips +void Helix::getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixListVIPsError; + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + // No point pagi/pajanating, Twitch's max VIP count doesn't go over 100 + // TODO(jammehcow): probably still implement pagination + // as the mod list can go over 100 (I assume, I see no limit) + urlQuery.addQueryItem("first", "100"); + + this->makeRequest("channels/vips", urlQuery) + .type(NetworkRequestType::Get) + .header("Content-Type", "application/json") + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting VIPs was" << result.status() + << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + + std::vector channelVips; + for (const auto &jsonStream : response.value("data").toArray()) + { + channelVips.emplace_back(jsonStream.toObject()); + } + + successCallback(channelVips); + 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.compare( + "The ID in broadcaster_id must match the user " + "ID found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + // Must be the broadcaster. + failureCallback(Error::UserNotBroadcaster, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error listing VIPs:" << 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 00f7037f..0e491f3c 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -329,6 +329,23 @@ struct HelixChatSettings { } }; +struct HelixVip { + QString userId; + QString userName; + QString userLogin; + + explicit HelixVip(const QJsonObject &jsonObject) + : userId(jsonObject.value("user_id").toString()) + , userName(jsonObject.value("user_name").toString()) + , userLogin(jsonObject.value("user_login").toString()) + { + } +}; + +// 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 + enum class HelixAnnouncementColor { Blue, Green, @@ -502,6 +519,17 @@ enum class HelixWhisperError { // /w Forwarded, }; // /w +enum class HelixListVIPsError { // /vips + Unknown, + UserMissingScope, + UserNotAuthorized, + UserNotBroadcaster, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /vips + class IHelix { public: @@ -756,6 +784,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips + virtual void getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1011,6 +1045,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-vips + void getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index b341b9b3..44a8d236 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -432,6 +432,10 @@ public: "/misc/twitch/helix-timegate/whisper", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateVIPs = { + "/misc/twitch/helix-timegate/vips", + HelixTimegateOverride::Timegate, + }; IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 22104129..e209c0f8 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -800,6 +800,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateWhisper->setMinimumWidth( helixTimegateWhisper->minimumSizeHint().width()); + auto *helixTimegateVIPs = + layout.addDropdown::type>( + "Helix timegate /vips behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateVIPs, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateVIPs->setMinimumWidth( + helixTimegateVIPs->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index e5bdb04b..982fc280 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -360,6 +360,15 @@ public: (FailureCallback failureCallback)), (override)); // /w + // /vips + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getChannelVIPs, + (QString broadcasterID, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /vips + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));