diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b1f5983..91f6a2d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ - Minor: Migrated /clear command to Helix API. (#3994) - Minor: Migrated /color command to Helix API. (#3988) - Minor: Migrated /delete command to Helix API. (#3999) +- Minor: Migrated /emoteonly command to Helix API. (#4015) +- Minor: Migrated /emoteonlyoff command to Helix API. (#4015) - Minor: Migrated /mod command to Helix API. (#4000) - Minor: Migrated /unmod command to Helix API. (#4001) - Minor: Migrated /vip command to Helix API. (#4010) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index f29684a60..9c4298a55 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2105,6 +2105,122 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // /raid + + const auto formatChatSettingsError = + [](const HelixUpdateChatSettingsError error, const QString &message) { + QString errorMessage = QString("Failed to update - "); + using Error = HelixUpdateChatSettingsError; + 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::Forwarded: { + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand("/emoteonly", [formatChatSettingsError]( + const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /emoteonly command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->emoteOnly) + { + channel->addMessage( + makeSystemMessage("This room is already in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode( + twitchChannel->roomId(), currentUser->getUserId(), true, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand( + "/emoteonlyoff", [formatChatSettingsError]( + const QStringList & /* words */, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /emoteonlyoff command only works in Twitch channels")); + return ""; + } + + if (!twitchChannel->accessRoomModes()->emoteOnly) + { + channel->addMessage( + makeSystemMessage("This room is not in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode( + twitchChannel->roomId(), currentUser->getUserId(), false, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index f385be8be..53225c0b0 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1530,6 +1530,170 @@ void Helix::startRaid( .execute(); } +void Helix::updateEmoteMode( + QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["emote_mode"] = emoteMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["follower_mode"] = followerModeDuration.has_value(); + if (followerModeDuration) + { + json["follower_mode_duration"] = *followerModeDuration; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["non_moderator_chat_delay"] = + nonModeratorChatDelayDuration.has_value(); + if (nonModeratorChatDelayDuration) + { + json["non_moderator_chat_delay_duration"] = + *nonModeratorChatDelayDuration; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateSlowMode( + QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["slow_mode"] = slowModeWaitTime.has_value(); + if (slowModeWaitTime) + { + json["slow_mode_wait_time"] = *slowModeWaitTime; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["subscriber_mode"] = subscriberMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["unique_chat_mode"] = uniqueChatMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject payload, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUpdateChatSettingsError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + this->makeRequest("chat/settings", urlQuery) + .type(NetworkRequestType::Patch) + .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 chat settings was" + << result.status() << "but we expected it to be 200"; + } + auto response = result.parseJson(); + successCallback(HelixChatSettings( + response.value("data").toArray().first().toObject())); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 409: + case 422: + case 425: { + 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; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error updating chat settings:" + << 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 e6e742201..9f9670800 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -300,6 +300,35 @@ struct HelixChannelEmote { } }; +struct HelixChatSettings { + const QString broadcasterId; + const bool emoteMode; + // boost::none if disabled + const boost::optional followerModeDuration; // time in minutes + const boost::optional + nonModeratorChatDelayDuration; // time in seconds + const boost::optional slowModeWaitTime; // time in seconds + const bool subscriberMode; + const bool uniqueChatMode; + + explicit HelixChatSettings(QJsonObject jsonObject) + : broadcasterId(jsonObject.value("broadcaster_id").toString()) + , emoteMode(jsonObject.value("emote_mode").toBool()) + , followerModeDuration(boost::make_optional( + jsonObject.value("follower_mode").toBool(), + jsonObject.value("follower_mode_duration").toInt())) + , nonModeratorChatDelayDuration(boost::make_optional( + jsonObject.value("non_moderator_chat_delay").toBool(), + jsonObject.value("non_moderator_chat_delay_duration").toInt())) + , slowModeWaitTime(boost::make_optional( + jsonObject.value("slow_mode").toBool(), + jsonObject.value("slow_mode_wait_time").toInt())) + , subscriberMode(jsonObject.value("subscriber_mode").toBool()) + , uniqueChatMode(jsonObject.value("unique_chat_mode").toBool()) + { + } +}; + enum class HelixAnnouncementColor { Blue, Green, @@ -425,6 +454,16 @@ enum class HelixStartRaidError { // /raid Forwarded, }; // /raid +enum class HelixUpdateChatSettingsError { // update chat settings + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + Forbidden, + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // update chat settings + class IHelix { public: @@ -607,7 +646,67 @@ public: FailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // Updates the emote mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateEmoteMode( + QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the follower mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the non-moderator chat delay using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the slow mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateSlowMode( + QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the subscriber mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the unique chat mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void update(QString clientId, QString oauthToken) = 0; + +protected: + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; }; class Helix final : public IHelix @@ -783,10 +882,68 @@ public: FailureCallback failureCallback) final; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // Updates the emote mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateEmoteMode(QString broadcasterID, QString moderatorID, + bool emoteMode, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Updates the follower mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the non-moderator chat delay using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the slow mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateSlowMode(QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Updates the subscriber mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the unique chat mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); +protected: + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index d8c02530b..622218d3c 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandController.hpp" #include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -540,9 +541,14 @@ std::unique_ptr SplitHeader::createChatModeMenu() setFollowers->setChecked(roomModes->followerOnly != -1); }); - auto toggle = [this](const QString &command, QAction *action) mutable { - this->split_->getChannel().get()->sendMessage( - command + (action->isChecked() ? "" : "off")); + auto execCommand = [this](const QString &command) { + auto text = getApp()->getCommands()->execCommand( + command, this->split_->getChannel(), false); + this->split_->getChannel()->sendMessage(text); + }; + auto toggle = [execCommand](const QString &command, + QAction *action) mutable { + execCommand(command + (action->isChecked() ? "" : "off")); action->setChecked(!action->isChecked()); }; @@ -556,51 +562,51 @@ std::unique_ptr SplitHeader::createChatModeMenu() toggle("/emoteonly", setEmote); }); - QObject::connect(setSlow, &QAction::triggered, this, [setSlow, this]() { - if (!setSlow->isChecked()) - { - this->split_->getChannel().get()->sendMessage("/slowoff"); - setSlow->setChecked(false); - return; - }; - auto ok = bool(); - auto seconds = QInputDialog::getInt(this, "", "Seconds:", 10, 0, 500, 1, - &ok, Qt::FramelessWindowHint); - if (ok) - { - this->split_->getChannel().get()->sendMessage( - QString("/slow %1").arg(seconds)); - } - else - { - setSlow->setChecked(false); - } - }); - QObject::connect( - setFollowers, &QAction::triggered, this, [setFollowers, this]() { - if (!setFollowers->isChecked()) + setSlow, &QAction::triggered, this, [setSlow, this, execCommand]() { + if (!setSlow->isChecked()) { - this->split_->getChannel().get()->sendMessage("/followersoff"); - setFollowers->setChecked(false); + execCommand("/slowoff"); + setSlow->setChecked(false); return; }; auto ok = bool(); - auto time = QInputDialog::getText( - this, "", "Time:", QLineEdit::Normal, "15m", &ok, - Qt::FramelessWindowHint, - Qt::ImhLowercaseOnly | Qt::ImhPreferNumbers); + auto seconds = + QInputDialog::getInt(this, "", "Seconds:", 10, 0, 500, 1, &ok, + Qt::FramelessWindowHint); if (ok) { - this->split_->getChannel().get()->sendMessage( - QString("/followers %1").arg(time)); + execCommand(QString("/slow %1").arg(seconds)); } else { - setFollowers->setChecked(false); + setSlow->setChecked(false); } }); + QObject::connect(setFollowers, &QAction::triggered, this, + [setFollowers, this, execCommand]() { + if (!setFollowers->isChecked()) + { + execCommand("/followersoff"); + setFollowers->setChecked(false); + return; + }; + auto ok = bool(); + auto time = QInputDialog::getText( + this, "", "Time:", QLineEdit::Normal, "15m", &ok, + Qt::FramelessWindowHint, + Qt::ImhLowercaseOnly | Qt::ImhPreferNumbers); + if (ok) + { + execCommand(QString("/followers %1").arg(time)); + } + else + { + setFollowers->setChecked(false); + } + }); + QObject::connect(setR9k, &QAction::triggered, this, [setR9k, toggle]() mutable { toggle("/r9kbeta", setR9k); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 0b329be86..1ad014c98 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace chatterino; using ::testing::Exactly; @@ -281,8 +282,71 @@ public: (FailureCallback failureCallback)), (override)); // /raid + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateEmoteMode, + (QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateFollowerMode, + (QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateNonModeratorChatDelay, + (QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateSlowMode, + (QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateSubscriberMode, + (QString broadcasterID, QString moderatorID, + bool subscriberMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateUniqueChatMode, + (QString broadcasterID, QString moderatorID, + bool uniqueChatMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // update chat settings + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); + +protected: + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateChatSettings, + (QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); }; static QString DEFAULT_SETTINGS = R"!(