Migrate /color command to Helix API (#3988)

This commit is contained in:
pajlada 2022-09-16 23:15:28 +02:00 committed by GitHub
parent c6ebb70e05
commit 4f1976b1be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 270 additions and 0 deletions

View file

@ -34,6 +34,7 @@
- Minor: Added whitespace trim to username field in nicknames (#3946)
- Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953)
- Minor: Added link back to original message that was deleted. (#3953)
- Minor: Migrate /color command to Helix API. (#3988)
- Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852)
- Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716)
- Bugfix: Fix crash that can occur when changing channels. (#3799)

View file

@ -2,6 +2,7 @@
#include "Application.hpp"
#include "common/Env.hpp"
#include "common/QLogging.hpp"
#include "common/SignalVector.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/commands/Command.hpp"
@ -1195,6 +1196,79 @@ void CommandController::initialize(Settings &, Paths &paths)
crossPlatformCopy(words.mid(1).join(" "));
return "";
});
this->registerCommand("/color", [](const QStringList &words, auto channel) {
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 /color command"));
return "";
}
auto colorString = words.value(1);
if (colorString.isEmpty())
{
channel->addMessage(makeSystemMessage(
QString("Usage: /color <color> - Color must be one of Twitch's "
"supported colors (%1) or a hex code (#000000) if you "
"have Turbo or Prime.")
.arg(VALID_HELIX_COLORS.join(", "))));
return "";
}
cleanHelixColorName(colorString);
getHelix()->updateUserChatColor(
user->getUserId(), colorString,
[colorString, channel] {
QString successMessage =
QString("Your color has been changed to %1.")
.arg(colorString);
channel->addMessage(makeSystemMessage(successMessage));
},
[colorString, channel](auto error, auto message) {
QString errorMessage =
QString("Failed to change color to %1 - ").arg(colorString);
switch (error)
{
case HelixUpdateUserChatColorError::UserMissingScope: {
errorMessage +=
"Missing required scope. Re-login with your "
"account and try again.";
}
break;
case HelixUpdateUserChatColorError::InvalidColor: {
errorMessage += QString("Color must be one of Twitch's "
"supported colors (%1) or a "
"hex code (#000000) if you "
"have Turbo or Prime.")
.arg(VALID_HELIX_COLORS.join(", "));
}
break;
case HelixUpdateUserChatColorError::Forwarded: {
errorMessage += message + ".";
}
break;
case HelixUpdateUserChatColorError::Unknown:
default: {
errorMessage += "An unknown error has occurred.";
}
break;
}
channel->addMessage(makeSystemMessage(errorMessage));
});
return "";
});
}
void CommandController::save()

View file

@ -770,6 +770,79 @@ void Helix::getChannelEmotes(
.execute();
}
void Helix::updateUserChatColor(
QString userID, QString color, ResultCallback<> successCallback,
FailureCallback<HelixUpdateUserChatColorError, QString> failureCallback)
{
using Error = HelixUpdateUserChatColorError;
QJsonObject payload;
payload.insert("user_id", QJsonValue(userID));
payload.insert("color", QJsonValue(color));
this->makeRequest("chat/color", QUrlQuery())
.type(NetworkRequestType::Put)
.header("Content-Type", "application/json")
.payload(QJsonDocument(payload).toJson(QJsonDocument::Compact))
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto obj = result.parseJson();
if (result.status() != 204)
{
qCWarning(chatterinoTwitch)
<< "Success result for updating chat color was"
<< result.status() << "but we only expected it to be 204";
}
successCallback();
return Success;
})
.onError([failureCallback](auto result) {
auto obj = result.parseJson();
auto message = obj.value("message").toString();
switch (result.status())
{
case 400: {
if (message.startsWith("invalid color",
Qt::CaseInsensitive))
{
// Handle this error specifically since it allows us to list out the available colors
failureCallback(Error::InvalidColor, message);
}
else
{
failureCallback(Error::Forwarded, message);
}
}
break;
case 401: {
if (message.startsWith("Missing scope",
Qt::CaseInsensitive))
{
// Handle this error specifically because its API error is especially unfriendly
failureCallback(Error::UserMissingScope, message);
}
else
{
failureCallback(Error::Forwarded, message);
}
}
break;
default: {
qCDebug(chatterinoTwitch)
<< "Unhandled error changing user color:"
<< result.status() << result.getData() << obj;
failureCallback(Error::Unknown, message);
}
break;
}
})
.execute();
};
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
{
assert(!url.startsWith("/"));

View file

@ -320,9 +320,21 @@ enum class HelixAutoModMessageError {
MessageNotFound,
};
enum class HelixUpdateUserChatColorError {
Unknown,
UserMissingScope,
InvalidColor,
// The error message is forwarded directly from the Twitch API
Forwarded,
};
class IHelix
{
public:
template <typename... T>
using FailureCallback = std::function<void(T...)>;
// https://dev.twitch.tv/docs/api/reference#get-users
virtual void fetchUsers(
QStringList userIds, QStringList userLogins,
@ -440,6 +452,12 @@ public:
ResultCallback<std::vector<HelixChannelEmote>> successCallback,
HelixFailureCallback failureCallback) = 0;
// https://dev.twitch.tv/docs/api/reference#update-user-chat-color
virtual void updateUserChatColor(
QString userID, QString color, ResultCallback<> successCallback,
FailureCallback<HelixUpdateUserChatColorError, QString>
failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0;
};
@ -556,6 +574,12 @@ public:
ResultCallback<std::vector<HelixChannelEmote>> successCallback,
HelixFailureCallback failureCallback) final;
// https://dev.twitch.tv/docs/api/reference#update-user-chat-color
void updateUserChatColor(
QString userID, QString color, ResultCallback<> successCallback,
FailureCallback<HelixUpdateUserChatColorError, QString> failureCallback)
final;
void update(QString clientId, QString oauthToken) final;
static void initialize();

View file

@ -6,6 +6,18 @@ this folder describes what sort of API requests we do, what permissions are requ
Full Helix API reference: https://dev.twitch.tv/docs/api/reference
### Adding support for a new endpoint
If you're adding support for a new endpoint, these are the things you should know.
1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible.
1. Override the virtual function in the `Helix` class.
1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file.
1. (Optional) Make a new error enum for the failure callback.
For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`.
The API is used in the "/color" command in [CommandController.cpp](../../../controllers/commands/CommandController.cpp)
### Get Users
URL: https://dev.twitch.tv/docs/api/reference#get-users

View file

@ -1,16 +1,37 @@
#include "util/Twitch.hpp"
#include "util/QStringHash.hpp"
#include <QDesktopServices>
#include <QUrl>
#include <unordered_map>
namespace chatterino {
namespace {
const auto TWITCH_USER_LOGIN_PATTERN = R"(^[a-z0-9]\w{0,24}$)";
// Remember to keep VALID_HELIX_COLORS up-to-date if a new color is implemented to keep naming for users consistent
const std::unordered_map<QString, QString> HELIX_COLOR_REPLACEMENTS{
{"blueviolet", "blue_violet"}, {"cadetblue", "cadet_blue"},
{"dodgerblue", "dodger_blue"}, {"goldenrod", "golden_rod"},
{"hotpink", "hot_pink"}, {"orangered", "orange_red"},
{"seagreen", "sea_green"}, {"springgreen", "spring_green"},
{"yellowgreen", "yellow_green"},
};
} // namespace
// Colors retreived from https://dev.twitch.tv/docs/api/reference#update-user-chat-color 2022-09-11
// Remember to keep HELIX_COLOR_REPLACEMENTS up-to-date if a new color is implemented to keep naming for users consistent
extern const QStringList VALID_HELIX_COLORS{
"blue", "blue_violet", "cadet_blue", "chocolate", "coral",
"dodger_blue", "firebrick", "golden_rod", "green", "hot_pink",
"orange_red", "red", "sea_green", "spring_green", "yellow_green",
};
void openTwitchUsercard(QString channel, QString username)
{
QDesktopServices::openUrl("https://www.twitch.tv/popout/" + channel +
@ -57,4 +78,17 @@ QRegularExpression twitchUserLoginRegexp()
return re;
}
void cleanHelixColorName(QString &color)
{
color = color.toLower();
auto it = HELIX_COLOR_REPLACEMENTS.find(color);
if (it == HELIX_COLOR_REPLACEMENTS.end())
{
return;
}
color = it->second;
}
} // namespace chatterino

View file

@ -2,9 +2,12 @@
#include <QRegularExpression>
#include <QString>
#include <QStringList>
namespace chatterino {
extern const QStringList VALID_HELIX_COLORS;
void openTwitchUsercard(const QString channel, const QString username);
// stripUserName removes any @ prefix or , suffix to make it more suitable for command use
@ -25,4 +28,9 @@ QRegularExpression twitchUserLoginRegexp();
// Must not start with an underscore
QRegularExpression twitchUserNameRegexp();
// Cleans up a color name input for use in the Helix API
// Will help massage color names like BlueViolet to the helix-acceptible blue_violet
// Will also lowercase the color
void cleanHelixColorName(QString &color);
} // namespace chatterino

View file

@ -209,6 +209,14 @@ public:
HelixFailureCallback failureCallback),
(override));
// The extra parenthesis around the failure callback is because its type contains a comma
MOCK_METHOD(void, updateUserChatColor,
(QString userID, QString color,
ResultCallback<> successCallback,
(FailureCallback<HelixUpdateUserChatColorError, QString>
failureCallback)),
(override));
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
(override));
};

View file

@ -264,3 +264,39 @@ TEST(UtilTwitch, UserNameRegexp)
<< qUtf8Printable(inputUserLogin) << " did not match as expected";
}
}
TEST(UtilTwitch, CleanHelixColor)
{
struct TestCase {
QString inputColor;
QString expectedColor;
};
std::vector<TestCase> tests{
{"foo", "foo"},
{"BlueViolet", "blue_violet"},
{"blueviolet", "blue_violet"},
{"DODGERBLUE", "dodger_blue"},
{"blUEviolet", "blue_violet"},
{"caDEtblue", "cadet_blue"},
{"doDGerblue", "dodger_blue"},
{"goLDenrod", "golden_rod"},
{"hoTPink", "hot_pink"},
{"orANgered", "orange_red"},
{"seAGreen", "sea_green"},
{"spRInggreen", "spring_green"},
{"yeLLowgreen", "yellow_green"},
{"xDxD", "xdxd"},
};
for (const auto &[inputColor, expectedColor] : tests)
{
QString actualColor = inputColor;
cleanHelixColorName(actualColor);
EXPECT_EQ(actualColor, expectedColor)
<< qUtf8Printable(inputColor) << " cleaned up to "
<< qUtf8Printable(actualColor) << " instead of "
<< qUtf8Printable(expectedColor);
}
}