diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cae8c3..e094ffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 70226251..d610cd75 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -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 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() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 953d7d98..27f2ebcf 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -770,6 +770,79 @@ void Helix::getChannelEmotes( .execute(); } +void Helix::updateUserChatColor( + QString userID, QString color, ResultCallback<> successCallback, + FailureCallback 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("/")); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 6e7ef55a..5531ba22 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -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 + using FailureCallback = std::function; + // https://dev.twitch.tv/docs/api/reference#get-users virtual void fetchUsers( QStringList userIds, QStringList userLogins, @@ -440,6 +452,12 @@ public: ResultCallback> 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 + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -556,6 +574,12 @@ public: ResultCallback> successCallback, HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#update-user-chat-color + void updateUserChatColor( + QString userID, QString color, ResultCallback<> successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 31a7e742..b8897618 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -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 diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp index 3bf82192..12e75198 100644 --- a/src/util/Twitch.cpp +++ b/src/util/Twitch.cpp @@ -1,16 +1,37 @@ #include "util/Twitch.hpp" +#include "util/QStringHash.hpp" + #include #include +#include + 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 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 diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp index 08cb8eb5..c3bb346a 100644 --- a/src/util/Twitch.hpp +++ b/src/util/Twitch.hpp @@ -2,9 +2,12 @@ #include #include +#include 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 diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 81d9029d..959ecc32 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -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 + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; diff --git a/tests/src/UtilTwitch.cpp b/tests/src/UtilTwitch.cpp index 36450490..c02753ca 100644 --- a/tests/src/UtilTwitch.cpp +++ b/tests/src/UtilTwitch.cpp @@ -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 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); + } +}