From f00f766eebdc8247cbc4a0e2a161a17e3031aba1 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sat, 5 Nov 2022 05:43:31 -0400 Subject: [PATCH] Migrate /commercial command to the Helix API (#4094) Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 142 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 92 ++++++++++++ src/providers/twitch/api/Helix.hpp | 42 ++++++ src/singletons/Settings.hpp | 5 + src/widgets/settingspages/GeneralPage.cpp | 11 ++ tests/src/HighlightController.cpp | 9 ++ 7 files changed, 302 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338f434b..459955d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) +- Minor: Migrated /commercial to Helix API. (#4094) - 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, #4097) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 1f8308aa..575272ee 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -3033,6 +3033,55 @@ void CommandController::initialize(Settings &, Paths &paths) return errorMessage; }; + auto formatStartCommercialError = [](HelixStartCommercialError error, + const QString &message) -> QString { + using Error = HelixStartCommercialError; + + QString errorMessage = "Failed to start commercial - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::TokenMustMatchBroadcaster: { + errorMessage += "Only the broadcaster of the channel can run " + "commercials."; + } + break; + + case Error::BroadcasterNotStreaming: { + errorMessage += "You must be streaming live to run " + "commercials."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You must wait until your cooldown period " + "expires before you can run another " + "commercial."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).").arg(message); + } + break; + } + + return errorMessage; + }; + this->registerCommand( "/vips", [formatVIPListError](const QStringList &words, @@ -3174,6 +3223,99 @@ void CommandController::initialize(Settings &, Paths &paths) "/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) { return uniqueChatLambda(words, channel, true); }); + + this->registerCommand( + "/commercial", + [formatStartCommercialError](const QStringList &words, + auto channel) -> QString { + const auto *usageStr = "Usage: \"/commercial \" - Starts a " + "commercial with the " + "specified duration for the current " + "channel. Valid length options " + "are 30, 60, 90, 120, 150, and 180 seconds."; + + switch (getSettings()->helixTimegateCommercial.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; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to use the /commercial command")); + return ""; + } + + auto *tc = dynamic_cast(channel.get()); + if (tc == nullptr) + { + return ""; + } + + auto broadcasterID = tc->roomId(); + auto length = words.at(1).toInt(); + + // We would prefer not to early out here and rather handle the API error + // like the rest of them, but the API doesn't give us a proper length error. + // Valid lengths can be found in the length body parameter description + // https://dev.twitch.tv/docs/api/reference#start-commercial + const QList validLengths = {30, 60, 90, 120, 150, 180}; + if (!validLengths.contains(length)) + { + channel->addMessage(makeSystemMessage( + "Invalid commercial duration length specified. Valid " + "options " + "are 30, 60, 90, 120, 150, and 180 seconds")); + return ""; + } + + getHelix()->startCommercial( + broadcasterID, length, + [channel](auto response) { + channel->addMessage(makeSystemMessage( + QString("Starting commercial break. Keep in mind you " + "are still " + "live and not all viewers will receive a " + "commercial. " + "You may run another commercial in %1 seconds.") + .arg(response.retryAfter))); + }, + [channel, formatStartCommercialError](auto error, + auto message) { + auto errorMessage = + formatStartCommercialError(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 3b0ba491..35a7ca5c 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2199,6 +2199,98 @@ void Helix::getChannelVIPs( .execute(); } +void Helix::startCommercial( + QString broadcasterID, int length, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixStartCommercialError; + + QJsonObject payload; + + payload.insert("broadcaster_id", QJsonValue(broadcasterID)); + payload.insert("length", QJsonValue(length)); + + this->makeRequest("channels/commercial", QUrlQuery()) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto obj = result.parseJson(); + if (obj.isEmpty()) + { + failureCallback( + Error::Unknown, + "Twitch didn't send any information about this error."); + return Failure; + } + + successCallback(HelixStartCommercialResponse(obj)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains( + "To start a commercial, the broadcaster must " + "be streaming live.", + Qt::CaseInsensitive)) + { + failureCallback(Error::BroadcasterNotStreaming, + message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.contains( + "The ID in broadcaster_id must match the user ID " + "found in the request's OAuth token.", + Qt::CaseInsensitive)) + { + failureCallback(Error::TokenMustMatchBroadcaster, + message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + // The cooldown period is implied to be included + // in the error's "retry_after" response field but isn't. + // If this becomes available we should append that to the error message. + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error starting commercial:" + << 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 3ec4157f..ece503bc 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -564,6 +564,34 @@ enum class HelixListVIPsError { // /vips Forwarded, }; // /vips +struct HelixStartCommercialResponse { + // Length of the triggered commercial + int length; + // Provides contextual information on why the request failed + QString message; + // Seconds until the next commercial can be served on this channel + int retryAfter; + + explicit HelixStartCommercialResponse(const QJsonObject &jsonObject) + { + auto jsonData = jsonObject.value("data").toArray().at(0).toObject(); + this->length = jsonData.value("length").toInt(); + this->message = jsonData.value("message").toString(); + this->retryAfter = jsonData.value("retry_after").toInt(); + } +}; + +enum class HelixStartCommercialError { + Unknown, + TokenMustMatchBroadcaster, + UserMissingScope, + BroadcasterNotStreaming, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -832,6 +860,13 @@ public: ResultCallback> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#start-commercial + virtual void startCommercial( + QString broadcasterID, int length, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1101,6 +1136,13 @@ public: ResultCallback> successCallback, FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#start-commercial + void startCommercial( + QString broadcasterID, int length, + 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 a56b79b2..57920a25 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -443,6 +443,11 @@ public: HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateCommercial = { + "/misc/twitch/helix-timegate/commercial", + 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 3a3c6afe..a626efc4 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -855,6 +855,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateVIPs->setMinimumWidth( helixTimegateVIPs->minimumSizeHint().width()); + auto *helixTimegateCommercial = + layout.addDropdown::type>( + "Helix timegate /commercial behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateCommercial, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateCommercial->setMinimumWidth( + helixTimegateCommercial->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 36a49df7..00870e6b 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -378,6 +378,15 @@ public: (FailureCallback failureCallback)), (override)); // /vips + // /commercial + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, startCommercial, + (QString broadcasterID, int length, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // /commercial + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));