From 0cfd25ce8e94e30fdb183f31ce121ae2dd965dfd Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 25 Feb 2024 14:45:55 +0100 Subject: [PATCH] feat: Send messages using Helix API (#5200) --- CHANGELOG.md | 1 + mocks/include/mocks/Helix.hpp | 8 ++ src/providers/twitch/TwitchIrcServer.cpp | 142 +++++++++++++++++++--- src/providers/twitch/TwitchIrcServer.hpp | 11 +- src/providers/twitch/api/Helix.cpp | 88 ++++++++++++++ src/providers/twitch/api/Helix.hpp | 60 +++++++++ src/singletons/Settings.hpp | 9 ++ src/widgets/settingspages/GeneralPage.cpp | 7 ++ 8 files changed, 306 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b576a9e0..749438123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ - Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123) - Dev: Specialize `Atomic>` if underlying standard library supports it. (#5133) - Dev: Added the `developer_name` field to the Linux AppData specification. (#5138) +- Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200) ## 2.4.6 diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 1771b1e2b..f53f62dda 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -392,6 +392,14 @@ public: (FailureCallback failureCallback)), (override)); + // send message + MOCK_METHOD( + void, sendChatMessage, + (HelixSendMessageArgs args, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index d7fbdc4d0..9a71c89ac 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -31,9 +31,94 @@ using namespace std::chrono_literals; namespace { +using namespace chatterino; + const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws"; const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3"; +void sendHelixMessage(const std::shared_ptr &channel, + const QString &message, const QString &replyParentId = {}) +{ + getHelix()->sendChatMessage( + { + .broadcasterID = channel->roomId(), + .senderID = + getIApp()->getAccounts()->twitch.getCurrent()->getUserId(), + .message = message, + .replyParentMessageID = replyParentId, + }, + [weak = std::weak_ptr(channel)](const auto &res) { + auto chan = weak.lock(); + if (!chan) + { + return; + } + + if (res.isSent) + { + return; + } + + auto errorMessage = [&] { + if (res.dropReason) + { + return makeSystemMessage(res.dropReason->message); + } + return makeSystemMessage("Your message was not sent."); + }(); + chan->addMessage(errorMessage); + }, + [weak = std::weak_ptr(channel)](auto error, const auto &message) { + auto chan = weak.lock(); + if (!chan) + { + return; + } + + using Error = decltype(error); + + auto errorMessage = [&]() -> QString { + switch (error) + { + case Error::MissingText: + return "You can't send an empty message."; + case Error::BadRequest: + return "Failed to send message: " + message; + case Error::Forbidden: + return "You are not allowed to send messages in this " + "channel."; + case Error::MessageTooLarge: + return "Your message was too long."; + case Error::UserMissingScope: + return "Missing required scope. Re-login with your " + "account and try again."; + case Error::Forwarded: + return message; + case Error::Unknown: + default: + return "Unknown error: " + message; + } + }(); + chan->addMessage(makeSystemMessage(errorMessage)); + }); +} + +/// Returns true if chat messages should be sent over Helix +bool shouldSendHelixChat() +{ + switch (getSettings()->chatSendProtocol) + { + case ChatSendProtocol::Helix: + return true; + case ChatSendProtocol::Default: + case ChatSendProtocol::IRC: + return false; + default: + assert(false && "Invalid chat protocol value"); + return false; + } +} + } // namespace namespace chatterino { @@ -139,13 +224,24 @@ std::shared_ptr TwitchIrcServer::createChannel( // no Channel's should live // NOTE: CHANNEL_LIFETIME std::ignore = channel->sendMessageSignal.connect( - [this, channel = channel.get()](auto &chan, auto &msg, bool &sent) { - this->onMessageSendRequested(channel, msg, sent); + [this, channel = std::weak_ptr(channel)](auto &chan, auto &msg, + bool &sent) { + auto c = channel.lock(); + if (!c) + { + return; + } + this->onMessageSendRequested(c, msg, sent); }); std::ignore = channel->sendReplySignal.connect( - [this, channel = channel.get()](auto &chan, auto &msg, auto &replyId, - bool &sent) { - this->onReplySendRequested(channel, msg, replyId, sent); + [this, channel = std::weak_ptr(channel)](auto &chan, auto &msg, + auto &replyId, bool &sent) { + auto c = channel.lock(); + if (!c) + { + return; + } + this->onReplySendRequested(c, msg, replyId, sent); }); return channel; @@ -436,7 +532,8 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const // return getSettings()->twitchSeperateWriteConnection; } -bool TwitchIrcServer::prepareToSend(TwitchChannel *channel) +bool TwitchIrcServer::prepareToSend( + const std::shared_ptr &channel) { std::lock_guard guard(this->lastMessageMutex_); @@ -487,8 +584,9 @@ bool TwitchIrcServer::prepareToSend(TwitchChannel *channel) return true; } -void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel, - const QString &message, bool &sent) +void TwitchIrcServer::onMessageSendRequested( + const std::shared_ptr &channel, const QString &message, + bool &sent) { sent = false; @@ -498,13 +596,21 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel, return; } - this->sendMessage(channel->getName(), message); + if (shouldSendHelixChat()) + { + sendHelixMessage(channel, message); + } + else + { + this->sendMessage(channel->getName(), message); + } + sent = true; } -void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel, - const QString &message, - const QString &replyId, bool &sent) +void TwitchIrcServer::onReplySendRequested( + const std::shared_ptr &channel, const QString &message, + const QString &replyId, bool &sent) { sent = false; @@ -514,9 +620,15 @@ void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel, return; } - this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" + - channel->getName() + " :" + message); - + if (shouldSendHelixChat()) + { + sendHelixMessage(channel, message, replyId); + } + else + { + this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" + + channel->getName() + " :" + message); + } sent = true; } diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 53db35964..5fef49084 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -101,12 +101,13 @@ protected: bool hasSeparateWriteConnection() const override; private: - void onMessageSendRequested(TwitchChannel *channel, const QString &message, - bool &sent); - void onReplySendRequested(TwitchChannel *channel, const QString &message, - const QString &replyId, bool &sent); + void onMessageSendRequested(const std::shared_ptr &channel, + const QString &message, bool &sent); + void onReplySendRequested(const std::shared_ptr &channel, + const QString &message, const QString &replyId, + bool &sent); - bool prepareToSend(TwitchChannel *channel); + bool prepareToSend(const std::shared_ptr &channel); std::mutex lastMessageMutex_; std::queue lastMessagePleb_; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 1c5a0ee3f..2a3b9a14e 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2840,6 +2840,94 @@ void Helix::sendShoutout( .execute(); } +// https://dev.twitch.tv/docs/api/reference/#send-chat-message +void Helix::sendChatMessage( + HelixSendMessageArgs args, ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendMessageError; + + QJsonObject json{{ + {"broadcaster_id", args.broadcasterID}, + {"sender_id", args.senderID}, + {"message", args.message}, + }}; + if (!args.replyParentMessageID.isEmpty()) + { + json["reply_parent_message_id"] = args.replyParentMessageID; + } + + this->makePost("chat/messages", {}) + .json(json) + .onSuccess([successCallback](const NetworkResult &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for sending chat message was " + << result.formatError() << "but we expected it to be 200"; + } + auto json = result.parseJson(); + + successCallback(HelixSentMessage( + json.value("data").toArray().at(0).toObject())); + }) + .onError([failureCallback](const NetworkResult &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = + obj["message"].toString(u"Twitch internal server error"_s); + + switch (*result.status()) + { + case 400: { + failureCallback(Error::Unknown, message); + } + break; + + case 401: { + if (message.startsWith("User access token requires the", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::Forbidden, message); + } + break; + + case 422: { + failureCallback(Error::MessageTooLarge, message); + } + break; + + case 500: { + failureCallback(Error::Unknown, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix send chat message, unhandled error data:" + << result.formatError() << 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 500eba60a..0d5412ba3 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -412,6 +412,41 @@ struct HelixGlobalBadges { using HelixChannelBadges = HelixGlobalBadges; +struct HelixDropReason { + QString code; + QString message; + + explicit HelixDropReason(const QJsonObject &jsonObject) + : code(jsonObject["code"].toString()) + , message(jsonObject["message"].toString()) + { + } +}; + +struct HelixSentMessage { + QString id; + bool isSent; + std::optional dropReason; + + explicit HelixSentMessage(const QJsonObject &jsonObject) + : id(jsonObject["message_id"].toString()) + , isSent(jsonObject["is_sent"].toBool()) + , dropReason(jsonObject.contains("drop_reason") + ? std::optional(HelixDropReason( + jsonObject["drop_reason"].toObject())) + : std::nullopt) + { + } +}; + +struct HelixSendMessageArgs { + QString broadcasterID; + QString senderID; + QString message; + /// Optional + QString replyParentMessageID; +}; + enum class HelixAnnouncementColor { Blue, Green, @@ -696,6 +731,19 @@ enum class HelixGetGlobalBadgesError { Forwarded, }; +enum class HelixSendMessageError { + Unknown, + + MissingText, + BadRequest, + Forbidden, + MessageTooLarge, + UserMissingScope, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + struct HelixError { /// Text version of the HTTP error that happened (e.g. Bad Request) QString error; @@ -1027,6 +1075,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference/#send-chat-message + virtual void sendChatMessage( + HelixSendMessageArgs args, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1341,6 +1395,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference/#send-chat-message + void sendChatMessage( + HelixSendMessageArgs args, + 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 daed3e6ac..7da5cf690 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -62,6 +62,12 @@ enum UsernameRightClickBehavior : int { Ignore = 2, }; +enum class ChatSendProtocol : int { + Default = 0, + IRC = 1, + Helix = 2, +}; + /// 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 @@ -524,6 +530,9 @@ public: HelixTimegateOverride::Timegate, }; + EnumStringSetting chatSendProtocol = { + "/misc/chatSendProtocol", ChatSendProtocol::Default}; + BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; EnumSetting emotesTooltipPreview = { diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index a7c2899b4..3bcf4dbd4 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -1243,6 +1243,13 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateModerators->setMinimumWidth( helixTimegateModerators->minimumSizeHint().width()); + layout.addDropdownEnumClass( + "Chat send protocol", magic_enum::enum_names(), + s.chatSendProtocol, + "'Helix' will use Twitch's Helix API to send message. 'IRC' will use " + "IRC to send messages.", + {}); + layout.addCheckbox( "Show send message button", s.showSendButton, false, "Show a Send button next to each split input that can be "