From 28de3e637dff94e1a9884888db8889ad8df34b72 Mon Sep 17 00:00:00 2001 From: Aiden Date: Fri, 23 Sep 2022 17:12:34 +0100 Subject: [PATCH] Migrate /mod command to Helix API (#4000) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 111 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 89 ++++++++++++++ src/providers/twitch/api/Helix.hpp | 24 ++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 233 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382cbb32..8214e5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Minor: Migrate /color command to Helix API. (#3988) - Minor: Migrate /clear command to Helix API. (#3994) - Minor: Migrate /delete command to Helix API. (#3999) +- Minor: Migrate /mod command to Helix API. (#4000) - 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 c84a6458..bc183dcc 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1355,6 +1355,117 @@ void CommandController::initialize(Settings &, Paths &paths) return deleteMessages(channel, messageID); }); + + this->registerCommand("/mod", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/mod \" - Grant moderator status to a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to mod someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /mod command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->addChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a moderator of this " + "channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to add channel moderator - "); + + using Error = HelixAddChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetIsVIP: { + errorMessage += + QString("%1 is currently a VIP, \"/unvip\" " + "them and " + "retry this command.") + .arg(targetUser.displayName); + } + break; + + case Error::TargetAlreadyModded: { + // Equivalent irc error + errorMessage = + QString("%1 is already a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 68bb96c0..43989e5d 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -929,6 +929,95 @@ void Helix::deleteChatMessages( .execute(); } +void Helix::addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixAddChannelModeratorError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("moderation/moderators", urlQuery) + .type(NetworkRequestType::Post) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for deleting chat messages 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 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0) + { + // This error is pretty ugly, but essentially means they're not authorized to mod people in this channel + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 400: { + if (message.compare("user is already a mod", + Qt::CaseInsensitive) == 0) + { + // This error is particularly ugly, handle it separately + failureCallback(Error::TargetAlreadyModded, message); + } + else + { + // The Twitch API error sufficiently tells the user what went wrong + failureCallback(Error::Forwarded, message); + } + } + break; + + case 422: { + // Target is already a VIP + failureCallback(Error::TargetIsVIP, message); + } + break; + + case 429: { + // Endpoint has a strict ratelimit + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error adding channel moderator:" + << 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 742e86d1..8363224a 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -340,6 +340,18 @@ enum class HelixDeleteChatMessagesError { Forwarded, }; +enum class HelixAddChannelModeratorError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + TargetAlreadyModded, + TargetIsVIP, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -476,6 +488,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + virtual void addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -605,6 +623,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + void addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index bb824c41..3ded5bd2 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -225,6 +225,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, addChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); };