diff --git a/CHANGELOG.md b/CHANGELOG.md index 459955d1..ec0728ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Minor: Migrated /chatters to Helix API. (#4088, #4097) +- Minor: Migrated /mods to Helix API. (#4103) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 575272ee..3ea04cd6 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -938,6 +938,91 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + auto formatModsError = [](HelixGetModeratorsError error, QString message) { + using Error = HelixGetModeratorsError; + + QString errorMessage = QString("Failed to get moderators: "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of mods you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand( + "/mods", + [formatModsError](const QStringList &words, auto channel) -> QString { + switch (getSettings()->helixTimegateModerators.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + case HelixTimegateOverride::AlwaysUseHelix: { + // Fall through to helix logic + } + break; + } + + auto twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /mods command only works in Twitch Channels")); + return ""; + } + + getHelix()->getModerators( + twitchChannel->roomId(), 500, + [channel, twitchChannel](auto result) { + // TODO: sort results? + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + "The moderators of this channel are", result, + twitchChannel, &builder); + channel->addMessage(builder.release()); + }, + [channel, formatModsError](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; + }); + this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { if (const auto type = channel->getType(); type != Channel::Type::Twitch && diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 91e0c753..d48f10fe 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1657,6 +1657,62 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, } } +void TwitchMessageBuilder::listOfUsersSystemMessage( + QString prefix, const std::vector &users, Channel *channel, + MessageBuilder *builder) +{ + QString text = prefix; + + builder->emplace(); + builder->message().flags.set(MessageFlag::System); + builder->message().flags.set(MessageFlag::DoNotTriggerNotification); + builder->emplace(prefix, MessageElementFlag::Text, + MessageColor::System); + bool isFirst = true; + auto *tc = dynamic_cast(channel); + for (const auto &user : users) + { + if (!isFirst) + { + // this is used to add the ", " after each but the last entry + builder->emplace(",", MessageElementFlag::Text, + MessageColor::System); + text += QString(", %1").arg(user.userName); + } + else + { + text += user.userName; + } + isFirst = false; + + MessageColor color = MessageColor::System; + + if (tc && getSettings()->colorUsernames) + { + if (auto userColor = tc->getUserColor(user.userLogin); + userColor.isValid()) + { + color = MessageColor(userColor); + } + } + + builder + ->emplace(user.userName, + MessageElementFlag::BoldUsername, color, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, user.userLogin}) + ->setTrailingSpace(false); + builder + ->emplace(user.userName, + MessageElementFlag::NonBoldUsername, color) + ->setLink({Link::UserInfo, user.userLogin}) + ->setTrailingSpace(false); + } + + builder->message().messageText = text; + builder->message().searchText = text; +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 7e01f900..74bb00a8 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -7,6 +7,7 @@ #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/api/Helix.hpp" #include #include @@ -77,6 +78,9 @@ public: static void listOfUsersSystemMessage(QString prefix, QStringList users, Channel *channel, MessageBuilder *builder); + static void listOfUsersSystemMessage( + QString prefix, const std::vector &users, + Channel *channel, MessageBuilder *builder); // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 35a7ca5c..a35f5d00 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -6,6 +6,14 @@ #include #include +namespace { + +using namespace chatterino; + +static constexpr auto NUM_MODERATORS_TO_FETCH_PER_REQUEST = 100; + +} // namespace + namespace chatterino { static IHelix *instance = nullptr; @@ -1859,6 +1867,115 @@ void Helix::fetchChatters( .execute(); } +void Helix::onFetchModeratorsSuccess( + std::shared_ptr> finalModerators, + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback, + HelixModerators moderators) +{ + qCDebug(chatterinoTwitch) + << "Fetched " << moderators.moderators.size() << " moderators"; + + std::for_each(moderators.moderators.begin(), moderators.moderators.end(), + [finalModerators](auto mod) { + finalModerators->push_back(mod); + }); + + if (moderators.cursor.isEmpty() || + finalModerators->size() >= maxModeratorsToFetch) + { + // Done paginating + successCallback(*finalModerators); + return; + } + + this->fetchModerators( + broadcasterID, NUM_MODERATORS_TO_FETCH_PER_REQUEST, moderators.cursor, + [=](auto moderators) { + this->onFetchModeratorsSuccess( + finalModerators, broadcasterID, maxModeratorsToFetch, + successCallback, failureCallback, moderators); + }, + failureCallback); +} + +// https://dev.twitch.tv/docs/api/reference#get-moderators +void Helix::fetchModerators( + QString broadcasterID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetModeratorsError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("first", QString::number(first)); + + if (!after.isEmpty()) + { + urlQuery.addQueryItem("after", after); + } + + this->makeRequest("moderation/moderators", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting moderators was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixModerators(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains("OAuth token")) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error data:" << result.status() + << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + // Ban/timeout a user // https://dev.twitch.tv/docs/api/reference#ban-user void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, @@ -2106,6 +2223,25 @@ void Helix::getChatters( fetchSuccess(fetchSuccess), failureCallback); } +// https://dev.twitch.tv/docs/api/reference#get-moderators +void Helix::getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) +{ + auto finalModerators = std::make_shared>(); + + // Initiate the recursive calls + this->fetchModerators( + broadcasterID, NUM_MODERATORS_TO_FETCH_PER_REQUEST, "", + [=](auto moderators) { + this->onFetchModeratorsSuccess( + finalModerators, broadcasterID, maxModeratorsToFetch, + successCallback, failureCallback, moderators); + }, + failureCallback); +} + // List the VIPs of a channel // https://dev.twitch.tv/docs/api/reference#get-vips void Helix::getChannelVIPs( diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index ece503bc..f056ccd6 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -332,8 +332,13 @@ struct HelixChatSettings { }; struct HelixVip { + // Twitch ID of the user QString userId; + + // Display name of the user QString userName; + + // Login name of the user QString userLogin; explicit HelixVip(const QJsonObject &jsonObject) @@ -367,9 +372,29 @@ struct HelixChatters { } }; -// TODO(jammehcow): when implementing mod list, just alias HelixVip to HelixMod -// as they share the same model. -// Alternatively, rename base struct to HelixUser or something and alias both +using HelixModerator = HelixVip; + +struct HelixModerators { + std::vector moderators; + QString cursor; + + HelixModerators() = default; + + explicit HelixModerators(const QJsonObject &jsonObject) + : cursor(jsonObject.value("pagination") + .toObject() + .value("cursor") + .toString()) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &mod : data) + { + HelixModerator moderator(mod.toObject()); + + this->moderators.push_back(moderator); + } + } +}; enum class HelixAnnouncementColor { Blue, @@ -553,6 +578,15 @@ enum class HelixGetChattersError { Forwarded, }; +enum class HelixGetModeratorsError { + Unknown, + UserMissingScope, + UserNotAuthorized, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixListVIPsError { // /vips Unknown, UserMissingScope, @@ -854,6 +888,14 @@ public: ResultCallback successCallback, FailureCallback failureCallback) = 0; + // Get moderators from the `broadcasterID` channel + // This will follow the returned cursor + // https://dev.twitch.tv/docs/api/reference#get-moderators + virtual void getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips virtual void getChannelVIPs( QString broadcasterID, @@ -1130,6 +1172,15 @@ public: ResultCallback successCallback, FailureCallback failureCallback) final; + // Get moderators from the `broadcasterID` channel + // This will follow the returned cursor + // https://dev.twitch.tv/docs/api/reference#get-moderators + void getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) + final; + // https://dev.twitch.tv/docs/api/reference#get-vips void getChannelVIPs( QString broadcasterID, @@ -1162,6 +1213,21 @@ protected: ResultCallback successCallback, FailureCallback failureCallback); + // Recursive boy + void onFetchModeratorsSuccess( + std::shared_ptr> finalModerators, + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback, + HelixModerators moderators); + + // Get moderator list - This method is what actually runs the API request + // https://dev.twitch.tv/docs/api/reference#get-moderators + void fetchModerators( + QString broadcasterID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback); + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 57920a25..4fd2172b 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -442,6 +442,10 @@ public: "/misc/twitch/helix-timegate/vips", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateModerators = { + "/misc/twitch/helix-timegate/moderators", + HelixTimegateOverride::Timegate, + }; EnumSetting helixTimegateCommercial = { "/misc/twitch/helix-timegate/commercial", diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index a626efc4..efeab936 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -866,6 +866,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateCommercial->setMinimumWidth( helixTimegateCommercial->minimumSizeHint().width()); + auto *helixTimegateModerators = + layout.addDropdown::type>( + "Helix timegate /mods behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateModerators, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateModerators->setMinimumWidth( + helixTimegateModerators->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 7df5084d..6f05c59c 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -391,6 +391,15 @@ public: (FailureCallback failureCallback)), (override)); // /commercial + // /mods + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getModerators, + (QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /mods + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));