diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ff6b93..12b1f598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) +- Minor: Migrated /raid command to Helix API. (#4029) - 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/common/QLogging.cpp b/src/common/QLogging.cpp index 3d343de3..9f4346a3 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -33,9 +33,10 @@ Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader", Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", logThreshold); -Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); +Q_LOGGING_CATEGORY(chatterinoSettings, "chatterino.settings", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); +Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index d9a0a0ee..79ef9d69 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -25,8 +25,9 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); -Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); +Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); +Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index ba1b87fa..f29684a6 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -38,8 +38,30 @@ #include namespace { + using namespace chatterino; +bool areIRCCommandsStillAvailable() +{ + // TODO: time-gate + return true; +} + +QString useIRCCommand(const QStringList &words) +{ + // Reform the original command + auto originalCommand = words.join(" "); + + // Replace the / with a . to pass it along to TMI + auto newCommand = originalCommand; + newCommand.replace(0, 1, "."); + + qCDebug(chatterinoTwitch) + << "Forwarding command" << originalCommand << "as" << newCommand; + + return newCommand; +} + void sendWhisperMessage(const QString &text) { // (hemirt) pajlada: "we should not be sending whispers through jtv, but @@ -1960,6 +1982,129 @@ void CommandController::initialize(Settings &, Paths &paths) // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch + + this->registerCommand( // /raid + "/raid", [](const QStringList &words, auto channel) -> QString { + switch (getSettings()->helixTimegateRaid.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/raid \" - Raid a user. " + "Only the broadcaster can start a raid.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to start a raid!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /raid command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->startRaid( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You started to raid %1.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to start a raid - "); + + using Error = HelixStartRaidError; + + 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: { + errorMessage += + "You must be the broadcaster " + "to start a raid."; + } + break; + + case Error::CantRaidYourself: { + errorMessage += + "A channel cannot raid itself."; + } + 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; + } + channel->addMessage( + makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); // /raid } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index e2c3999d..f385be8b 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1447,6 +1447,89 @@ void Helix::unbanUser( // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch +void Helix::startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixStartRaidError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); + urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); + + this->makeRequest("raids", urlQuery) + .type(NetworkRequestType::Post) + .onSuccess( + [successCallback, failureCallback](auto /*result*/) -> Outcome { + 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("The IDs in from_broadcaster_id and " + "to_broadcaster_id cannot be the same.", + Qt::CaseInsensitive) == 0) + { + failureCallback(Error::CantRaidYourself, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare( + "The ID in broadcaster_id must match the user " + "ID " + "found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + // Must be the broadcaster. + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 409: { + failureCallback(Error::Forwarded, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error while starting a raid:" + << 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 3bc6af36..e6e74220 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -414,6 +414,17 @@ enum class HelixUnbanUserError { Forwarded, }; // These changes are from the helix-command-migration/unban-untimeout branch +enum class HelixStartRaidError { // /raid + Unknown, + UserMissingScope, + UserNotAuthorized, + CantRaidYourself, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /raid + class IHelix { public: @@ -589,6 +600,13 @@ public: FailureCallback failureCallback) = 0; // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#start-a-raid + virtual void startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#start-a-raid + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -758,6 +776,13 @@ public: FailureCallback failureCallback) final; // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#start-a-raid + void startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#start-a-raid + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index c21be1d6..4ca8e383 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -58,6 +58,20 @@ enum UsernameDisplayMode : int { LocalizedName = 2, // Localized name UsernameAndLocalizedName = 3, // Username (Localized name) }; + +enum HelixTimegateOverride : int { + // Use the default timegated behaviour + // This means we use the old IRC command up until the migration date and + // switch over to the Helix API only after the migration date + Timegate = 1, + + // Ignore timegating and always force use the IRC command + AlwaysUseIRC = 2, + + // Ignore timegating and always force use the Helix API + AlwaysUseHelix = 3, +}; + /// Settings which are availlable for reading and writing on the gui thread. // These settings are still accessed concurrently in the code but it is bad practice. class Settings : public ABSettings, public ConcurrentSettings @@ -395,6 +409,12 @@ public: 800, }; + // Temporary time-gate-overrides + EnumSetting helixTimegateRaid = { + "/misc/twitch/helix-timegate/raid", + HelixTimegateOverride::Timegate, + }; + IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index b3ed259a..4358ceb9 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -1,10 +1,7 @@ -#include "GeneralPage.hpp" - -#include -#include -#include +#include "widgets/settingspages/GeneralPage.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" #include "common/Version.hpp" #include "singletons/Fonts.hpp" #include "singletons/NativeMessaging.hpp" @@ -21,6 +18,9 @@ #include #include +#include +#include +#include #define CHROME_EXTENSION_LINK \ "https://chrome.google.com/webstore/detail/chatterino-native-host/" \ @@ -720,6 +720,52 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Messages in /mentions highlights tab", s.highlightMentions); + // Helix timegate settings + auto helixTimegateGetValue = [](auto val) { + switch (val) + { + case HelixTimegateOverride::Timegate: + return "Timegate"; + case HelixTimegateOverride::AlwaysUseIRC: + return "Always use IRC"; + case HelixTimegateOverride::AlwaysUseHelix: + return "Always use Helix"; + default: + return "Timegate"; + } + }; + + auto helixTimegateSetValue = [](auto args) { + const auto &v = args.value; + if (v == "Timegate") + { + return HelixTimegateOverride::Timegate; + } + if (v == "Always use IRC") + { + return HelixTimegateOverride::AlwaysUseIRC; + } + if (v == "Always use Helix") + { + return HelixTimegateOverride::AlwaysUseHelix; + } + + qCDebug(chatterinoSettings) << "Unknown Helix timegate override value" + << v << ", using default value Timegate"; + return HelixTimegateOverride::Timegate; + }; + + auto *helixTimegateRaid = + layout.addDropdown::type>( + "Helix timegate /raid behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateRaid, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateRaid->setMinimumWidth( + helixTimegateRaid->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 4aef0dbf..0b329be8 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -273,6 +273,14 @@ public: (FailureCallback failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( // /raid + void, startRaid, + (QString fromBroadcasterID, QString toBroadcasterId, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /raid + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); };