diff --git a/CHANGELOG.md b/CHANGELOG.md index 2576d254a..8fa1b7f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ - Minor: Migrated /followers to Helix API. (#4040) - Minor: Migrated /followersoff to Helix API. (#4040) - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) +- Minor: Migrated /ban to Helix API. (#4049) +- Minor: Migrated /timeout to Helix API. (#4049) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a3f23edf3..0ca1cfb03 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2511,6 +2511,195 @@ void CommandController::initialize(Settings &, Paths &paths) }); return ""; }); + + auto formatBanTimeoutError = + [](const char *operation, HelixBanUserError error, + const QString &message, const QString &userDisplayName) -> QString { + using Error = HelixBanUserError; + + QString errorMessage = QString("Failed to %1 user - ").arg(operation); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetBanned: { + // Equivalent IRC error + errorMessage = QString("%1 is already banned in this channel.") + .arg(userDisplayName); + } + break; + + 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::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand("/timeout", [formatBanTimeoutError]( + const QStringList &words, + auto channel) { + const auto *usageStr = + "Usage: \"/timeout [duration][time unit] [reason]\" - " + "Temporarily prevent a user from chatting. Duration (optional, " + "default=10 minutes) must be a positive integer; time unit " + "(optional, default=s) must be one of s, m, h, d, w; maximum " + "duration is 2 weeks. Combinations like 1d2h are also allowed. " + "Reason is optional and will be shown to the target user and other " + "moderators. Use \"/untimeout\" to remove a timeout."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to timeout someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /timeout command only works in Twitch channels"))); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + int duration = 10 * 60; // 10min + if (words.size() >= 3) + { + duration = (int)parseDurationToSeconds(words.at(2)); + if (duration <= 0) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + } + auto reason = words.mid(3).join(' '); + + getHelix()->getUserByName( + target, + [channel, currentUser, twitchChannel, target, duration, reason, + formatBanTimeoutError](const auto &targetUser) { + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, duration, reason, + [] { + // No response for timeouts, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser, formatBanTimeoutError]( + auto error, auto message) { + auto errorMessage = formatBanTimeoutError( + "timeout", error, message, targetUser.displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); + + this->registerCommand("/ban", [formatBanTimeoutError]( + const QStringList &words, auto channel) { + const auto *usageStr = + "Usage: \"/ban [reason]\" - Permanently prevent a user " + "from chatting. Reason is optional and will be shown to the target " + "user and other moderators. Use \"/unban\" to remove a ban."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to ban someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /ban command only works in Twitch channels"))); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + auto reason = words.mid(2).join(' '); + + getHelix()->getUserByName( + target, + [channel, currentUser, twitchChannel, target, reason, + formatBanTimeoutError](const auto &targetUser) { + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, boost::none, reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser, formatBanTimeoutError]( + auto error, auto message) { + auto errorMessage = formatBanTimeoutError( + "ban", error, message, targetUser.displayName); + 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 959c482f3..d01ea5524 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1709,6 +1709,109 @@ void Helix::updateChatSettings( .execute(); } +// Ban/timeout a user +// https://dev.twitch.tv/docs/api/reference#ban-user +void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixBanUserError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + { + QJsonObject data; + data["reason"] = reason; + data["user_id"] = userID; + if (duration) + { + data["duration"] = *duration; + } + + payload["data"] = data; + } + + this->makeRequest("moderation/bans", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for banning a user was" + << result.status() << "but we expected it to be 200"; + } + // we don't care about the response + 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("The user specified in the user_id " + "field is already banned", + Qt::CaseInsensitive)) + { + failureCallback(Error::TargetBanned, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 409: { + failureCallback(Error::ConflictingOperation, 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; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error banning 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 839953778..f26f667ea 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -466,6 +466,18 @@ enum class HelixUpdateChatSettingsError { // update chat settings Forwarded, }; // update chat settings +enum class HelixBanUserError { // /timeout, /ban + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + ConflictingOperation, + TargetBanned, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /timeout, /ban + class IHelix { public: @@ -698,7 +710,14 @@ public: ResultCallback successCallback, FailureCallback failureCallback) = 0; - // https://dev.twitch.tv/docs/api/reference#update-chat-settings + + // Ban/timeout a user + // https://dev.twitch.tv/docs/api/reference#ban-user + virtual void banUser( + QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; virtual void update(QString clientId, QString oauthToken) = 0; @@ -934,6 +953,14 @@ public: FailureCallback failureCallback) final; + // Ban/timeout a user + // https://dev.twitch.tv/docs/api/reference#ban-user + void banUser( + QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + 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 1ad014c98..9cb4a9fe4 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -336,6 +336,15 @@ public: (override)); // update chat settings + // /timeout, /ban + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, banUser, + (QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /timeout, /ban + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));