diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d131fe76..e6ca8d19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Added `/shoutout ` commands to shoutout specified user. (#4638) - Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) - Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 792094fbf..e7fe4ef49 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -379,6 +379,14 @@ public: failureCallback)), (override)); + // /shoutout + MOCK_METHOD( + void, sendShoutout, + (QString fromBroadcasterID, QString toBroadcasterID, + QString moderatorID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fe4a44fa4..465a147f1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -66,6 +66,8 @@ set(SOURCE_FILES controllers/commands/builtin/twitch/ChatSettings.hpp controllers/commands/builtin/twitch/ShieldMode.cpp controllers/commands/builtin/twitch/ShieldMode.hpp + controllers/commands/builtin/twitch/Shoutout.cpp + controllers/commands/builtin/twitch/Shoutout.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 3c7c4febd..7be61286c 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -10,6 +10,7 @@ #include "controllers/commands/builtin/chatterino/Debugging.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" #include "controllers/commands/builtin/twitch/ShieldMode.hpp" +#include "controllers/commands/builtin/twitch/Shoutout.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" @@ -3213,6 +3214,8 @@ void CommandController::initialize(Settings &, Paths &paths) this->registerCommand("/shield", &commands::shieldModeOn); this->registerCommand("/shieldoff", &commands::shieldModeOff); + this->registerCommand("/shoutout", &commands::sendShoutout); + this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); } diff --git a/src/controllers/commands/builtin/twitch/Shoutout.cpp b/src/controllers/commands/builtin/twitch/Shoutout.cpp new file mode 100644 index 000000000..4e3a7c7d6 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/Shoutout.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx) +{ + auto *twitchChannel = ctx.twitchChannel; + auto channel = ctx.channel; + auto words = &ctx.words; + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /shoutout command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to send shoutout")); + return ""; + } + + if (words->size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: \"/shoutout \" - Sends a " + "shoutout to the specified twitch user")); + return ""; + } + + const auto target = words->at(1); + + using Error = HelixSendShoutoutError; + + getHelix()->getUserByName( + target, + [twitchChannel, channel, currentUser, &target](const auto targetUser) { + getHelix()->sendShoutout( + twitchChannel->roomId(), targetUser.id, + currentUser->getUserId(), + [channel, targetUser]() { + channel->addMessage(makeSystemMessage( + QString("Sent shoutout to %1").arg(targetUser.login))); + }, + [channel](auto error, auto message) { + QString errorMessage = "Failed to send shoutout - "; + + switch (error) + { + case Error::UserNotAuthorized: { + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. " + "Try again in a few seconds."; + } + break; + + case Error::UserIsBroadcaster: { + errorMessage += "The broadcaster may not give " + "themselves a Shoutout."; + } + break; + + case Error::BroadcasterNotLive: { + errorMessage += + "The broadcaster is not streaming live or " + "does not have one or more viewers."; + } + break; + + case Error::Unknown: { + errorMessage += message; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Shoutout.hpp b/src/controllers/commands/builtin/twitch/Shoutout.hpp new file mode 100644 index 000000000..ffeec03f8 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 662484c98..59329ae59 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2597,6 +2597,107 @@ void Helix::updateShieldMode( .execute(); } +// https://dev.twitch.tv/docs/api/reference/#send-a-shoutout +void Helix::sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendShoutoutError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); + urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + this->makePost("chat/shoutouts", urlQuery) + .header("Content-Type", "application/json") + .onSuccess([successCallback](NetworkResult result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending shoutout was " + << result.status() << "but we expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](NetworkResult result) -> void { + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("The broadcaster may not give " + "themselves a Shoutout.", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserIsBroadcaster, message); + } + else if (message.startsWith( + "The broadcaster is not streaming live or " + "does not have one or more viewers.", + Qt::CaseInsensitive)) + { + failureCallback(Error::BroadcasterNotLive, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + case 500: { + // Helix returns 500 when user is not mod, + if (message.isEmpty()) + { + failureCallback(Error::Unknown, + "Twitch internal server error"); + } + else + { + failureCallback(Error::Unknown, message); + } + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix send shoutout, unhandled error data:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + } + }) + .execute(); +} + NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery, NetworkRequestType type) { diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index f6dcc8f02..3a370a41e 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -637,6 +637,18 @@ enum class HelixListVIPsError { // /vips Forwarded, }; // /vips +enum class HelixSendShoutoutError { + Unknown, + // 400 + UserIsBroadcaster, + BroadcasterNotLive, + // 401 + UserNotAuthorized, + UserMissingScope, + + Ratelimited, +}; + struct HelixStartCommercialResponse { // Length of the triggered commercial int length; @@ -1013,6 +1025,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + virtual void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1318,6 +1336,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 173292ca6..83bc45e87 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -170,6 +170,14 @@ URL: https://dev.twitch.tv/docs/api/reference/#get-chatters Used for the viewer list for moderators/broadcasters. +### Send Shoutout + +URL: https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + +Used in: + +- `controllers/commands/CommandController.cpp` to send Twitch native shoutout using "/shoutout " + ## PubSub ### Whispers