From be22fe124f348e48b400ab2436e3cad6c3cb1ee3 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Thu, 25 Jan 2024 23:44:08 +0100 Subject: [PATCH 1/3] refactor: send messages over helix api --- mocks/include/mocks/Helix.hpp | 8 ++ src/providers/twitch/TwitchIrcServer.cpp | 126 +++++++++++++++++++--- src/providers/twitch/TwitchIrcServer.hpp | 11 +- src/providers/twitch/api/Helix.cpp | 88 +++++++++++++++ src/providers/twitch/api/Helix.hpp | 60 +++++++++++ src/singletons/Settings.hpp | 2 + src/widgets/settingspages/GeneralPage.cpp | 5 + 7 files changed, 280 insertions(+), 20 deletions(-) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 1771b1e2b8e..f53f62dda6d 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -392,6 +392,14 @@ class Helix : public IHelix (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 d7fbdc4d00c..5a903bbd2bf 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -31,9 +31,78 @@ 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)); + }); +} + } // namespace namespace chatterino { @@ -139,13 +208,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 +516,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 +568,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 +580,21 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel, return; } - this->sendMessage(channel->getName(), message); + if (getSettings()->enableHelixChatSend) + { + 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 +604,15 @@ void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel, return; } - this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" + - channel->getName() + " :" + message); - + if (getSettings()->enableHelixChatSend) + { + 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 53db359641e..5fef4908492 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -101,12 +101,13 @@ class TwitchIrcServer final : public AbstractIrcServer, 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 1c5a0ee3f10..9d46a89e120 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-a-shoutout +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 500eba60a01..0d5412ba3f0 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 @@ class IHelix 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 @@ class Helix final : public IHelix 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 daed3e6ac11..260605bf031 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -524,6 +524,8 @@ class Settings HelixTimegateOverride::Timegate, }; + BoolSetting enableHelixChatSend = {"/misc/helixChatSend", true}; + BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; EnumSetting emotesTooltipPreview = { diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index a7c2899b406..66a9c3e725b 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -1243,6 +1243,11 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateModerators->setMinimumWidth( helixTimegateModerators->minimumSizeHint().width()); + layout.addCheckbox("Send messages use Twitch's Helix API", + s.enableHelixChatSend, false, + "When enabled, sends messages using the Helix API. When " + "disabled, messages are sent over IRC."); + layout.addCheckbox( "Show send message button", s.showSendButton, false, "Show a Send button next to each split input that can be " From 1e60cb646d1302aabfbac9d07a442ca0c12e708e Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 25 Feb 2024 13:19:54 +0100 Subject: [PATCH 2/3] chore: add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b576a9e068..8e876ec7dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Minor: Updated to Emoji v15.1. Google emojis are now used as the fallback instead of Twitter emojis. (#5182) - Minor: Allow theming of tab live and rerun indicators. (#5188) - Minor: Added a fallback theme field to custom themes that will be used in case the custom theme does not contain a color Chatterino needs. If no fallback theme is specified, we'll pull the color from the included Dark or Light theme. (#5198) +- Minor: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5200) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) From b2df3e9fa788239e515ef24d8bece7da30717965 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 25 Feb 2024 13:47:58 +0100 Subject: [PATCH 3/3] refactor: default and changelog and comments --- CHANGELOG.md | 2 +- src/providers/twitch/TwitchIrcServer.cpp | 20 ++++++++++++++++++-- src/providers/twitch/api/Helix.cpp | 2 +- src/singletons/Settings.hpp | 9 ++++++++- src/widgets/settingspages/GeneralPage.cpp | 10 ++++++---- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e876ec7dee..749438123e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,6 @@ - Minor: Updated to Emoji v15.1. Google emojis are now used as the fallback instead of Twitter emojis. (#5182) - Minor: Allow theming of tab live and rerun indicators. (#5188) - Minor: Added a fallback theme field to custom themes that will be used in case the custom theme does not contain a color Chatterino needs. If no fallback theme is specified, we'll pull the color from the included Dark or Light theme. (#5198) -- Minor: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5200) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) @@ -162,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/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 5a903bbd2bf..9a71c89ac77 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -103,6 +103,22 @@ void sendHelixMessage(const std::shared_ptr &channel, }); } +/// 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 { @@ -580,7 +596,7 @@ void TwitchIrcServer::onMessageSendRequested( return; } - if (getSettings()->enableHelixChatSend) + if (shouldSendHelixChat()) { sendHelixMessage(channel, message); } @@ -604,7 +620,7 @@ void TwitchIrcServer::onReplySendRequested( return; } - if (getSettings()->enableHelixChatSend) + if (shouldSendHelixChat()) { sendHelixMessage(channel, message, replyId); } diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 9d46a89e120..2a3b9a14e10 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2840,7 +2840,7 @@ void Helix::sendShoutout( .execute(); } -// https://dev.twitch.tv/docs/api/reference/#send-a-shoutout +// https://dev.twitch.tv/docs/api/reference/#send-chat-message void Helix::sendChatMessage( HelixSendMessageArgs args, ResultCallback successCallback, FailureCallback failureCallback) diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 260605bf031..7da5cf690ed 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,7 +530,8 @@ class Settings HelixTimegateOverride::Timegate, }; - BoolSetting enableHelixChatSend = {"/misc/helixChatSend", true}; + EnumStringSetting chatSendProtocol = { + "/misc/chatSendProtocol", ChatSendProtocol::Default}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 66a9c3e725b..3bcf4dbd491 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -1243,10 +1243,12 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateModerators->setMinimumWidth( helixTimegateModerators->minimumSizeHint().width()); - layout.addCheckbox("Send messages use Twitch's Helix API", - s.enableHelixChatSend, false, - "When enabled, sends messages using the Helix API. When " - "disabled, messages are sent over IRC."); + 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,