diff --git a/CHANGELOG.md b/CHANGELOG.md index a18b53958..c8e30c487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Minor: Improved error messages when the updater fails a download. (#4594) +- Minor: Added `/shield` and `/shieldoff` commands to toggle shield mode. (#4580) - Bugfix: Fixed the menu warping on macOS on Qt6. (#4595) - Bugfix: Fixed link tooltips not showing unless the thumbnail setting was enabled. (#4597) - Dev: Added the ability to control the `followRedirect` mode for requests. (#4594) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a6de2b92e..753c8ce04 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -60,6 +60,8 @@ set(SOURCE_FILES controllers/commands/builtin/twitch/ChatSettings.cpp controllers/commands/builtin/twitch/ChatSettings.hpp + controllers/commands/builtin/twitch/ShieldMode.cpp + controllers/commands/builtin/twitch/ShieldMode.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 39b9293f7..a68f0545f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -8,6 +8,7 @@ #include "common/SignalVector.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" +#include "controllers/commands/builtin/twitch/ShieldMode.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" @@ -3207,6 +3208,9 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/shield", &commands::shieldModeOn); + this->registerCommand("/shieldoff", &commands::shieldModeOff); } void CommandController::save() diff --git a/src/controllers/commands/builtin/twitch/ShieldMode.cpp b/src/controllers/commands/builtin/twitch/ShieldMode.cpp new file mode 100644 index 000000000..2e9b7ca48 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ShieldMode.cpp @@ -0,0 +1,97 @@ +#include "controllers/commands/builtin/twitch/ShieldMode.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString toggleShieldMode(const CommandContext &ctx, bool isActivating) +{ + const QString command = + isActivating ? QStringLiteral("/shield") : QStringLiteral("/shieldoff"); + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + QStringLiteral("The %1 command only works in Twitch channels") + .arg(command))); + return {}; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + QStringLiteral("You must be logged in to use the %1 command") + .arg(command))); + return {}; + } + + getHelix()->updateShieldMode( + ctx.twitchChannel->roomId(), user->getUserId(), isActivating, + [channel = ctx.channel](const auto &res) { + if (!res.isActive) + { + channel->addMessage( + makeSystemMessage("Shield mode was deactivated.")); + return; + } + + channel->addMessage( + makeSystemMessage("Shield mode was activated.")); + }, + [channel = ctx.channel](const auto error, const auto &message) { + using Error = HelixUpdateShieldModeError; + QString errorMessage = "Failed to update shield mode - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::MissingPermission: { + errorMessage += "You must be a moderator of the channel."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).") + .arg(message); + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return {}; +} + +QString shieldModeOn(const CommandContext &ctx) +{ + return toggleShieldMode(ctx, true); +} + +QString shieldModeOff(const CommandContext &ctx) +{ + return toggleShieldMode(ctx, false); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/ShieldMode.hpp b/src/controllers/commands/builtin/twitch/ShieldMode.hpp new file mode 100644 index 000000000..fad2694e8 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ShieldMode.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString shieldModeOn(const CommandContext &ctx); +QString shieldModeOff(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 71c19175a..4dc4f1222 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2560,6 +2560,77 @@ void Helix::getChannelBadges( .execute(); } +// https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status +void Helix::updateShieldMode( + QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUpdateShieldModeError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + payload["is_active"] = isActive; + + this->makeRequest("moderation/shield_mode", urlQuery) + .type(NetworkRequestType::Put) + .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 updating shield mode was " + << result.status() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + successCallback( + HelixShieldModeStatus(response["data"][0].toObject())); + return Success; + }) + .onError([failureCallback](auto result) { + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + } + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + case 403: { + if (message.startsWith( + "Requester does not have permissions", + Qt::CaseInsensitive)) + { + failureCallback(Error::MissingPermission, message); + break; + } + } + + default: { + qCWarning(chatterinoTwitch) + << "Helix shield mode, unhandled error data:" + << 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 f3286a729..0492b0560 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -6,6 +6,7 @@ #include "util/QStringHash.hpp" #include +#include #include #include #include @@ -653,6 +654,39 @@ struct HelixStartCommercialResponse { } }; +struct HelixShieldModeStatus { + /// A Boolean value that determines whether Shield Mode is active. Is `true` if Shield Mode is active; otherwise, `false`. + bool isActive; + /// An ID that identifies the moderator that last activated Shield Mode. + QString moderatorID; + /// The moderator's login name. + QString moderatorLogin; + /// The moderator's display name. + QString moderatorName; + /// The UTC timestamp of when Shield Mode was last activated. + QDateTime lastActivatedAt; + + explicit HelixShieldModeStatus(const QJsonObject &json) + : isActive(json["is_active"].toBool()) + , moderatorID(json["moderator_id"].toString()) + , moderatorLogin(json["moderator_login"].toString()) + , moderatorName(json["moderator_name"].toString()) + , lastActivatedAt(QDateTime::fromString( + json["last_activated_at"].toString(), Qt::ISODate)) + { + this->lastActivatedAt.setTimeSpec(Qt::UTC); + } +}; + +enum class HelixUpdateShieldModeError { + Unknown, + UserMissingScope, + MissingPermission, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixStartCommercialError { Unknown, TokenMustMatchBroadcaster, @@ -972,6 +1006,13 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status + virtual void updateShieldMode( + QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1270,6 +1311,13 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status + void updateShieldMode(QString broadcasterID, QString moderatorID, + bool isActive, + 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 181ca57a3..f5609ed45 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -426,6 +426,14 @@ public: (FailureCallback failureCallback)), (override)); // /mods + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateShieldMode, + (QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));