diff --git a/CHANGELOG.md b/CHANGELOG.md index ac893c2ec..30938f4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Minor: Migrate /delete command to Helix API. (#3999) - Minor: Migrate /mod command to Helix API. (#4000) - Minor: Migrate /unmod command to Helix API. (#4001) +- Minor: Migrate /announce command to Helix API. (#4003) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 1b826cc08..b32becd80 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1568,6 +1568,70 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand( + "/announce", [](const QStringList &words, auto channel) -> QString { + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "This command can only be used in Twitch channels.")); + return ""; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: /announce - Call attention to your " + "message with a highlight.")); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to use the /announce command")); + return ""; + } + + getHelix()->sendChatAnnouncement( + twitchChannel->roomId(), user->getUserId(), + words.mid(1).join(" "), HelixAnnouncementColor::Primary, + []() { + // do nothing. + }, + [channel](auto error, auto message) { + using Error = HelixSendChatAnnouncementError; + QString errorMessage = + QString("Failed to send announcement - "); + + 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::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 4414c11d1..d151da322 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -4,6 +4,7 @@ #include "common/QLogging.hpp" #include +#include namespace chatterino { @@ -1098,6 +1099,85 @@ void Helix::removeChannelModerator( .execute(); } +void Helix::sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendChatAnnouncementError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject body; + body.insert("message", message); + const auto colorStr = + std::string{magic_enum::enum_name(color)}; + body.insert("color", QString::fromStdString(colorStr).toLower()); + + this->makeRequest("chat/announcements", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(body).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending an announcement was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + // These errors are generally well formatted, so we just forward them. + // This is currently undocumented behaviour, see: https://github.com/twitchdev/issues/issues/660 + failureCallback(Error::Forwarded, message); + } + break; + + case 403: { + // 403 endpoint means the user does not have permission to perform this action in that channel + // `message` value is well-formed so no need for a specific error type + 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; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error sending an announcement:" + << 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 f18669c03..c3e099506 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -300,6 +300,16 @@ struct HelixChannelEmote { } }; +enum class HelixAnnouncementColor { + Blue, + Green, + Orange, + Purple, + + // this is the executor's chat color + Primary, +}; + enum class HelixClipError { Unknown, ClipsDisabled, @@ -340,6 +350,14 @@ enum class HelixDeleteChatMessagesError { Forwarded, }; +enum class HelixSendChatAnnouncementError { + Unknown, + UserMissingScope, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixAddChannelModeratorError { Unknown, UserMissingScope, @@ -511,6 +529,13 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + virtual void sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -652,6 +677,13 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + void sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, 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 7b6bbab66..59219c03b 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -241,6 +241,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, sendChatAnnouncement, + (QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); };