Migrate /commercial command to the Helix API (#4094)
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -67,6 +67,7 @@
|
|||||||
- Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053)
|
- 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 /uniquechat and /r9kbeta to Helix API. (#4057)
|
||||||
- Minor: Migrated /uniquechatoff and /r9kbetaoff 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: Added stream titles to windows live toast notifications. (#1297)
|
||||||
- Minor: Make menus and placeholders display appropriate custom key combos. (#4045)
|
- Minor: Make menus and placeholders display appropriate custom key combos. (#4045)
|
||||||
- Minor: Migrated /chatters to Helix API. (#4088, #4097)
|
- Minor: Migrated /chatters to Helix API. (#4088, #4097)
|
||||||
|
|||||||
@@ -3033,6 +3033,55 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||||||
return errorMessage;
|
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(
|
this->registerCommand(
|
||||||
"/vips",
|
"/vips",
|
||||||
[formatVIPListError](const QStringList &words,
|
[formatVIPListError](const QStringList &words,
|
||||||
@@ -3174,6 +3223,99 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||||||
"/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) {
|
"/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) {
|
||||||
return uniqueChatLambda(words, channel, true);
|
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()
|
void CommandController::save()
|
||||||
|
|||||||
@@ -2199,6 +2199,98 @@ void Helix::getChannelVIPs(
|
|||||||
.execute();
|
.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)
|
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
|
||||||
{
|
{
|
||||||
assert(!url.startsWith("/"));
|
assert(!url.startsWith("/"));
|
||||||
|
|||||||
@@ -564,6 +564,34 @@ enum class HelixListVIPsError { // /vips
|
|||||||
Forwarded,
|
Forwarded,
|
||||||
}; // /vips
|
}; // /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
|
class IHelix
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -832,6 +860,13 @@ public:
|
|||||||
ResultCallback<std::vector<HelixVip>> successCallback,
|
ResultCallback<std::vector<HelixVip>> successCallback,
|
||||||
FailureCallback<HelixListVIPsError, QString> failureCallback) = 0;
|
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;
|
virtual void update(QString clientId, QString oauthToken) = 0;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -1101,6 +1136,13 @@ public:
|
|||||||
ResultCallback<std::vector<HelixVip>> successCallback,
|
ResultCallback<std::vector<HelixVip>> successCallback,
|
||||||
FailureCallback<HelixListVIPsError, QString> failureCallback) final;
|
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;
|
void update(QString clientId, QString oauthToken) final;
|
||||||
|
|
||||||
static void initialize();
|
static void initialize();
|
||||||
|
|||||||
@@ -443,6 +443,11 @@ public:
|
|||||||
HelixTimegateOverride::Timegate,
|
HelixTimegateOverride::Timegate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EnumSetting<HelixTimegateOverride> helixTimegateCommercial = {
|
||||||
|
"/misc/twitch/helix-timegate/commercial",
|
||||||
|
HelixTimegateOverride::Timegate,
|
||||||
|
};
|
||||||
|
|
||||||
IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1};
|
IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1};
|
||||||
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
||||||
|
|
||||||
|
|||||||
@@ -855,6 +855,17 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
|||||||
helixTimegateVIPs->setMinimumWidth(
|
helixTimegateVIPs->setMinimumWidth(
|
||||||
helixTimegateVIPs->minimumSizeHint().width());
|
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();
|
layout.addStretch();
|
||||||
|
|
||||||
// invisible element for width
|
// invisible element for width
|
||||||
|
|||||||
@@ -378,6 +378,15 @@ public:
|
|||||||
(FailureCallback<HelixListVIPsError, QString> failureCallback)),
|
(FailureCallback<HelixListVIPsError, QString> failureCallback)),
|
||||||
(override)); // /vips
|
(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),
|
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
|
||||||
(override));
|
(override));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user