Migrate /commercial command to the Helix API (#4094)

Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
xel86 2022-11-05 05:43:31 -04:00 committed by GitHub
parent f0ad606d7a
commit f00f766eeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 302 additions and 0 deletions

View file

@ -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)

View file

@ -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 <length>\" - 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<TwitchChannel *>(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<int> 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()

View file

@ -2199,6 +2199,98 @@ void Helix::getChannelVIPs(
.execute();
}
void Helix::startCommercial(
QString broadcasterID, int length,
ResultCallback<HelixStartCommercialResponse> successCallback,
FailureCallback<HelixStartCommercialError, QString> 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("/"));

View file

@ -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<std::vector<HelixVip>> successCallback,
FailureCallback<HelixListVIPsError, QString> failureCallback) = 0;
// https://dev.twitch.tv/docs/api/reference#start-commercial
virtual void startCommercial(
QString broadcasterID, int length,
ResultCallback<HelixStartCommercialResponse> successCallback,
FailureCallback<HelixStartCommercialError, QString>
failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0;
protected:
@ -1101,6 +1136,13 @@ public:
ResultCallback<std::vector<HelixVip>> successCallback,
FailureCallback<HelixListVIPsError, QString> failureCallback) final;
// https://dev.twitch.tv/docs/api/reference#start-commercial
void startCommercial(
QString broadcasterID, int length,
ResultCallback<HelixStartCommercialResponse> successCallback,
FailureCallback<HelixStartCommercialError, QString> failureCallback)
final;
void update(QString clientId, QString oauthToken) final;
static void initialize();

View file

@ -443,6 +443,11 @@ public:
HelixTimegateOverride::Timegate,
};
EnumSetting<HelixTimegateOverride> helixTimegateCommercial = {
"/misc/twitch/helix-timegate/commercial",
HelixTimegateOverride::Timegate,
};
IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1};
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};

View file

@ -855,6 +855,17 @@ void GeneralPage::initLayout(GeneralPageView &layout)
helixTimegateVIPs->setMinimumWidth(
helixTimegateVIPs->minimumSizeHint().width());
auto *helixTimegateCommercial =
layout.addDropdown<std::underlying_type<HelixTimegateOverride>::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

View file

@ -378,6 +378,15 @@ public:
(FailureCallback<HelixListVIPsError, QString> 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<HelixStartCommercialResponse> successCallback,
(FailureCallback<HelixStartCommercialError, QString> failureCallback)),
(override)); // /commercial
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
(override));