diff --git a/CHANGELOG.md b/CHANGELOG.md index 8214e5e7d..ac893c2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Minor: Migrate /clear command to Helix API. (#3994) - Minor: Migrate /delete command to Helix API. (#3999) - Minor: Migrate /mod command to Helix API. (#4000) +- Minor: Migrate /unmod command to Helix API. (#4001) - 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 bc183dcc4..1b826cc08 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1466,6 +1466,108 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/unmod", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/unmod \" - Revoke moderator status from 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 unmod someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /unmod command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->removeChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a moderator of " + "this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to remove channel moderator - "); + + using Error = HelixRemoveChannelModeratorError; + + 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::TargetNotModded: { + // Equivalent irc error + errorMessage += + QString("%1 is not 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 43989e5dd..4414c11d1 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1018,6 +1018,86 @@ void Helix::addChannelModerator( .execute(); } +void Helix::removeChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixRemoveChannelModeratorError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("moderation/moderators", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for unmodding user 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.compare("user is not a mod", + Qt::CaseInsensitive) == 0) + { + // This error message is particularly ugly, so we handle it differently + failureCallback(Error::TargetNotModded, 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 if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error unmodding user:" << 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 8363224a4..f18669c03 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -352,6 +352,17 @@ enum class HelixAddChannelModeratorError { Forwarded, }; +enum class HelixRemoveChannelModeratorError { + Unknown, + UserMissingScope, + UserNotAuthorized, + TargetNotModded, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -494,6 +505,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#remove-channel-moderator + virtual void removeChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -629,6 +646,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#remove-channel-moderator + void removeChannelModerator( + 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 3ded5bd25..7b6bbab66 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -233,6 +233,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, removeChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); };