diff --git a/CHANGELOG.md b/CHANGELOG.md index 3752b1d5..eab2696f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ - 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) +- Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) - 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 0ca1cfb0..40de938f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -181,28 +181,151 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) return true; } -bool appendWhisperMessageStringLocally(const QString &textNoEmoji) +bool useIrcForWhisperCommand() { - QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji); - QStringList words = text.split(' ', Qt::SkipEmptyParts); - - if (words.length() == 0) + switch (getSettings()->helixTimegateWhisper.getValue()) { - return false; - } + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return true; + } - QString commandName = words[0]; - - if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive)) - { - if (words.length() > 2) - { - return appendWhisperMessageWordsLocally(words); + // fall through to Helix logic } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return true; + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; } return false; } +QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel) +{ + if (words.size() < 3) + { + channel->addMessage( + makeSystemMessage("Usage: /w ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to send a whisper!")); + return ""; + } + auto target = words.at(1); + stripChannelName(target); + auto message = words.mid(2).join(' '); + + if (useIrcForWhisperCommand()) + { + if (channel->isTwitchChannel()) + { + appendWhisperMessageWordsLocally(words); + sendWhisperMessage(words.join(' ')); + } + else + { + channel->addMessage(makeSystemMessage( + "You can only send whispers from Twitch channels.")); + } + return ""; + } + + getHelix()->getUserByName( + target, + [channel, currentUser, target, message, words](const auto &targetUser) { + getHelix()->sendWhisper( + currentUser->getUserId(), targetUser.id, message, + [words] { + appendWhisperMessageWordsLocally(words); + }, + [channel, target, targetUser](auto error, auto message) { + using Error = HelixWhisperError; + + QString errorMessage = "Failed to send whisper - "; + + switch (error) + { + case Error::NoVerifiedPhone: { + errorMessage += + "Due to Twitch restrictions, you are now " + "required to have a verified phone number " + "to send whispers. You can add a phone " + "number in Twitch settings. " + "https://www.twitch.tv/settings/security"; + }; + break; + + case Error::RecipientBlockedUser: { + errorMessage += + "The recipient doesn't allow whispers " + "from strangers or you directly."; + }; + break; + + case Error::WhisperSelf: { + errorMessage += "You cannot whisper yourself."; + }; + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You may only whisper a maximum of 40 " + "unique recipients per day. Within the " + "per day limit, you may whisper a " + "maximum of 3 whispers per second and " + "a maximum of 100 whispers per minute."; + } + 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; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel] { + channel->addMessage( + makeSystemMessage("No user matching that username.")); + }); + + return ""; +} + using VariableReplacer = std::function; @@ -2700,6 +2823,13 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) + { + this->registerCommand(cmd, [](const QStringList &words, auto channel) { + return runWhisperCommand(words, channel); + }); + } } void CommandController::save() @@ -2728,26 +2858,6 @@ QString CommandController::execCommand(const QString &textNoEmoji, QString commandName = words[0]; - // works in a valid Twitch channel and /whispers, etc... - if (!dryRun && channel->isTwitchChannel()) - { - if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive)) - { - if (words.length() > 2) - { - appendWhisperMessageWordsLocally(words); - sendWhisperMessage(text); - } - else - { - channel->addMessage( - makeSystemMessage("Usage: /w ")); - } - - return ""; - } - } - { // check if user command exists const auto it = this->userCommands_.find(commandName); @@ -2811,7 +2921,7 @@ void CommandController::registerCommand(QString commandName, } QString CommandController::execCustomCommand( - const QStringList &words, const Command &command, bool dryRun, + const QStringList &words, const Command &command, bool /* dryRun */, ChannelPtr channel, const Message *message, std::unordered_map context) { @@ -2906,17 +3016,7 @@ QString CommandController::execCustomCommand( result = result.mid(1); } - auto res = result.replace("{{", "{"); - - if (dryRun || !appendWhisperMessageStringLocally(res)) - { - return res; - } - else - { - sendWhisperMessage(res); - return ""; - } + return result.replace("{{", "{"); } QStringList CommandController::getDefaultChatterinoCommandList() diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 5c601336..0afef8cc 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -911,8 +911,20 @@ std::vector IrcMessageHandler::parseNoticeMessage( // default case std::vector builtMessages; - builtMessages.emplace_back(makeSystemMessage( - message->content(), calculateMessageTime(message).time())); + auto content = message->content(); + if (content.startsWith( + "Your settings prevent you from sending this whisper", + Qt::CaseInsensitive) && + getSettings()->helixTimegateWhisper.getValue() == + HelixTimegateOverride::Timegate) + { + content = + content + + " Consider setting the \"Helix timegate /w " + "behaviour\" to \"Always use Helix\" in your Chatterino settings."; + } + builtMessages.emplace_back( + makeSystemMessage(content, calculateMessageTime(message).time())); return builtMessages; } diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index d01ea552..087febdf 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1812,6 +1812,112 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, .execute(); } +// https://dev.twitch.tv/docs/api/reference#send-whisper +void Helix::sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixWhisperError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("from_user_id", fromUserID); + urlQuery.addQueryItem("to_user_id", toUserID); + + QJsonObject payload; + payload["message"] = message; + + this->makeRequest("whispers", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending a whisper was" + << result.status() << "but we expected it to be 204"; + } + // 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("A user cannot whisper themself", + Qt::CaseInsensitive)) + { + failureCallback(Error::WhisperSelf, message); + } + else + { + 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 if (message.startsWith("the sender does not have a " + "verified phone number", + Qt::CaseInsensitive)) + { + failureCallback(Error::NoVerifiedPhone, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + if (message.startsWith("The recipient's settings prevent " + "this sender from whispering them", + Qt::CaseInsensitive)) + { + failureCallback(Error::RecipientBlockedUser, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 404: { + failureCallback(Error::Forwarded, 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 f26f667e..89a7ba68 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -478,6 +478,19 @@ enum class HelixBanUserError { // /timeout, /ban Forwarded, }; // /timeout, /ban +enum class HelixWhisperError { // /w + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + NoVerifiedPhone, + RecipientBlockedUser, + WhisperSelf, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /w + class IHelix { public: @@ -719,6 +732,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Send a whisper + // https://dev.twitch.tv/docs/api/reference#send-whisper + virtual void sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -961,6 +981,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Send a whisper + // https://dev.twitch.tv/docs/api/reference#send-whisper + void sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 4ca8e383..82dc1fb6 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -414,6 +414,10 @@ public: "/misc/twitch/helix-timegate/raid", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateWhisper = { + "/misc/twitch/helix-timegate/whisper", + 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 4358ceb9..74a885b7 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -766,6 +766,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateRaid->setMinimumWidth( helixTimegateRaid->minimumSizeHint().width()); + auto *helixTimegateWhisper = + layout.addDropdown::type>( + "Helix timegate /w behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateWhisper, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateWhisper->setMinimumWidth( + helixTimegateWhisper->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 9cb4a9fe..0cf5d609 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -345,6 +345,14 @@ public: (FailureCallback failureCallback)), (override)); // /timeout, /ban + // /w + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, sendWhisper, + (QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /w + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override));