From 33ab6149bf42bca0a4da7d62cee2e48c8753d1b1 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 11 Nov 2023 12:09:31 +0100 Subject: [PATCH 01/23] Add support for the Create EventSub Subscription Helix API --- mocks/include/mocks/Helix.hpp | 9 +++ src/providers/twitch/api/Helix.cpp | 109 +++++++++++++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 75 ++++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 1771b1e2b8e..7a123091265 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -392,6 +392,15 @@ class Helix : public IHelix (FailureCallback failureCallback)), (override)); + MOCK_METHOD(void, createEventSubSubscription, + (const QString &type, const QString &version, + const QString &sessionID, const QJsonObject &condition, + ResultCallback + successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 7fd2cf1ffa3..4fe52885275 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2836,6 +2836,115 @@ void Helix::sendShoutout( .execute(); } +void Helix::createEventSubSubscription( + const QString &type, const QString &version, const QString &sessionID, + const QJsonObject &condition, + ResultCallback successCallback, + FailureCallback + failureCallback) +{ + using Error = HelixCreateEventSubSubscriptionError; + + QJsonObject body; + body.insert("type", type); + body.insert("version", version); + body.insert("condition", condition); + + QJsonObject transport; + transport.insert("method", "websocket"); + transport.insert("session_id", sessionID); + + body.insert("transport", transport); + + this->makePost("eventsub/subscriptions", {}) + .json(body) + .onSuccess([successCallback](const auto &result) { + if (result.status() != 202) + { + qCWarning(chatterinoTwitch) + << "Success result for creating eventsub subscription was " + << result.formatError() << "but we expected it to be 202"; + } + + HelixCreateEventSubSubscriptionResponse response( + result.parseJson()); + + successCallback(response); + }) + .onError([failureCallback](const NetworkResult &result) { + if (!result.status()) + { + failureCallback(Error::Forwarded, result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (*result.status()) + { + case 400: { + failureCallback(Error::BadRequest, message); + } + break; + + case 401: { + failureCallback(Error::Unauthorized, message); + } + break; + + case 403: { + failureCallback(Error::Forbidden, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + case 500: { + if (message.isEmpty()) + { + failureCallback(Error::Forwarded, + "Twitch internal server error"); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix Create EventSub Subscription, unhandled " + "error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Forwarded, message); + } + } + }) + .execute(); +} + +QDebug &operator<<(QDebug &dbg, + const HelixCreateEventSubSubscriptionResponse &data) +{ + dbg << "HelixCreateEventSubSubscriptionResponse{ id:" << data.subscriptionID + << "status:" << data.subscriptionStatus + << "type:" << data.subscriptionType + << "version:" << data.subscriptionVersion + << "condition:" << data.subscriptionCondition + << "createdAt:" << data.subscriptionCreatedAt + << "sessionID:" << data.subscriptionSessionID + << "connectedAt:" << data.subscriptionConnectedAt + << "cost:" << data.subscriptionCost << "total:" << data.total + << "totalCost:" << data.totalCost + << "maxTotalCost:" << data.maxTotalCost << '}'; + return dbg; +} + 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 25668099497..6028935a301 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -706,6 +706,65 @@ struct HelixError { using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; +struct HelixCreateEventSubSubscriptionResponse { + QString subscriptionID; + QString subscriptionStatus; + QString subscriptionType; + QString subscriptionVersion; + QJsonObject subscriptionCondition; + QString subscriptionCreatedAt; + QString subscriptionSessionID; + QString subscriptionConnectedAt; + int subscriptionCost; + + int total; + int totalCost; + int maxTotalCost; + + explicit HelixCreateEventSubSubscriptionResponse( + const QJsonObject &jsonObject) + { + { + auto jsonData = jsonObject.value("data").toArray().at(0).toObject(); + this->subscriptionID = jsonData.value("id").toString(); + this->subscriptionStatus = jsonData.value("status").toString(); + this->subscriptionType = jsonData.value("type").toString(); + this->subscriptionVersion = jsonData.value("version").toString(); + this->subscriptionCondition = + jsonData.value("condition").toObject(); + this->subscriptionCreatedAt = + jsonData.value("created_at").toString(); + this->subscriptionSessionID = jsonData.value("transport") + .toObject() + .value("session_id") + .toString(); + this->subscriptionConnectedAt = jsonData.value("transport") + .toObject() + .value("connected_at") + .toString(); + this->subscriptionCost = jsonData.value("cost").toInt(); + } + + this->total = jsonObject.value("total").toInt(); + this->totalCost = jsonObject.value("total_cost").toInt(); + this->maxTotalCost = jsonObject.value("max_total_cost").toInt(); + } + + friend QDebug &operator<<( + QDebug &dbg, const HelixCreateEventSubSubscriptionResponse &data); +}; + +enum class HelixCreateEventSubSubscriptionError { + BadRequest, + Unauthorized, + Forbidden, + Conflict, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -1019,6 +1078,14 @@ class IHelix ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription + virtual void createEventSubSubscription( + const QString &type, const QString &version, const QString &sessionID, + const QJsonObject &condition, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1333,6 +1400,14 @@ class Helix final : public IHelix ResultCallback<> successCallback, FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription + void createEventSubSubscription( + const QString &type, const QString &version, const QString &sessionID, + const QJsonObject &condition, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); From b8b9870a4cf523c2a97a28a56c685643551acc54 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 12 Nov 2023 14:53:47 +0100 Subject: [PATCH 02/23] Add debug command to create an eventsub subscription --- .../commands/CommandController.cpp | 2 + .../commands/builtin/chatterino/Debugging.cpp | 81 +++++++++++++++++++ .../commands/builtin/chatterino/Debugging.hpp | 2 + 3 files changed, 85 insertions(+) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index dd9025fb77f..9843893e381 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -451,6 +451,8 @@ void CommandController::initialize(Settings &, const Paths &paths) this->registerCommand("/debug-force-image-unload", &commands::forceImageUnload); + this->registerCommand("/debug-eventsub", &commands::debugEventSub); + this->registerCommand("/shield", &commands::shieldModeOn); this->registerCommand("/shieldoff", &commands::shieldModeOff); diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index c72f0cde04c..0505cdf8cd5 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -1,12 +1,18 @@ #include "controllers/commands/builtin/chatterino/Debugging.hpp" +#include "Application.hpp" #include "common/Channel.hpp" #include "common/Env.hpp" #include "common/Literals.hpp" +#include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandContext.hpp" #include "messages/Image.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Theme.hpp" #include "util/PostToThread.hpp" @@ -134,4 +140,79 @@ QString forceImageUnload(const CommandContext &ctx) return ""; } +QString debugEventSub(const CommandContext &ctx) +{ + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("missing session ID")); + return {}; + } + + const auto &sessionID = ctx.words[1]; + + const auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("you must be logged in to use this command")); + return {}; + } + + auto sourceUserID = currentUser->getUserId(); + + getApp()->twitch->forEachChannelAndSpecialChannels( + [sessionID, sourceUserID](const ChannelPtr &channel) { + if (channel->getType() == Channel::Type::Twitch) + { + auto *twitchChannel = + dynamic_cast(channel.get()); + + auto roomID = twitchChannel->roomId(); + + if (channel->isBroadcaster()) + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + + getHelix()->createEventSubSubscription( + "channel.ban", "1", sessionID, condition, + [roomID](const auto &response) { + qDebug() << "Successfully subscribed to " + "channel.ban in" + << roomID << ":" << response; + }, + [roomID](auto error, const auto &message) { + (void)error; + qDebug() << "Failed subscription to channel.ban in" + << roomID << ":" << message; + }); + } + + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + condition.insert("user_id", sourceUserID); + + getHelix()->createEventSubSubscription( + "channel.chat.notification", "beta", sessionID, + condition, + [roomID](const auto &response) { + qDebug() << "Successfully subscribed to " + "channel.chat.notification in " + << roomID << ":" << response; + }, + [roomID](auto error, const auto &message) { + (void)error; + qDebug() << "Failed subscription to " + "channel.chat.notification in" + << roomID << ":" << message; + }); + } + } + }); + + return ""; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.hpp b/src/controllers/commands/builtin/chatterino/Debugging.hpp index 8d185737009..7e9a9e236a2 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.hpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.hpp @@ -22,4 +22,6 @@ QString forceImageGarbageCollection(const CommandContext &ctx); QString forceImageUnload(const CommandContext &ctx); +QString debugEventSub(const CommandContext &ctx); + } // namespace chatterino::commands From 4982f65b8b32bb25a665d7ec164136431ab73aa0 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 12 Nov 2023 14:55:56 +0100 Subject: [PATCH 03/23] Add dev changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b91e9e3d7ef..063e9264375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ - Dev: Refactor `common/Credentials`. (#4979) - Dev: Refactor chat logger. (#5058) - Dev: Refactor Twitch PubSub client. (#5059) +- Dev: Add EventSub Helix support. (#4962) - Dev: Changed lifetime of context menus. (#4924) - Dev: Renamed `tools` directory to `scripts`. (#5035) - Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926) From 30fee9ac8581a7363b663d86773454a394c5cbd1 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 11:20:09 +0100 Subject: [PATCH 04/23] Add eventsub class that uses the eventsub library this isn't a submodule yet xd --- CMakeLists.txt | 4 +- src/CMakeLists.txt | 13 + .../commands/builtin/chatterino/Debugging.cpp | 71 +--- src/providers/twitch/EventSub.cpp | 349 ++++++++++++++++++ src/providers/twitch/EventSub.hpp | 17 + 5 files changed, 385 insertions(+), 69 deletions(-) create mode 100644 src/providers/twitch/EventSub.cpp create mode 100644 src/providers/twitch/EventSub.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f6b8281e115..78a943c1abb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,7 +123,7 @@ endif () find_package(Sanitizers QUIET) # Find boost on the system -find_package(Boost REQUIRED OPTIONAL_COMPONENTS headers) +find_package(Boost REQUIRED OPTIONAL_COMPONENTS headers json) # Find OpenSSL on the system find_package(OpenSSL REQUIRED) @@ -213,6 +213,8 @@ if (BUILD_WITH_CRASHPAD) add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler") endif() +add_subdirectory("${CMAKE_SOURCE_DIR}/../beast-websocket-client" eventsub EXCLUDE_FROM_ALL) + # Used to provide a date of build in the About page (for nightly builds). Getting the actual time of # compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. # For CI runs, however, the date of build file generation should be consistent with the date of diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c22beef6cf4..910c65dae16 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -372,6 +372,8 @@ set(SOURCE_FILES providers/twitch/ChannelPointReward.cpp providers/twitch/ChannelPointReward.hpp + providers/twitch/EventSub.cpp + providers/twitch/EventSub.hpp providers/twitch/IrcMessageHandler.cpp providers/twitch/IrcMessageHandler.hpp providers/twitch/PubSubActions.cpp @@ -765,6 +767,12 @@ target_link_libraries(${LIBRARY_PROJECT} LRUCache MagicEnum ) + +target_link_libraries(${LIBRARY_PROJECT} + PUBLIC + eventsub + ) + if (CHATTERINO_PLUGINS) target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) endif() @@ -814,6 +822,11 @@ if (BUILD_APP) target_link_libraries(${EXECUTABLE_PROJECT} PUBLIC ${LIBRARY_PROJECT}) + target_link_libraries(${EXECUTABLE_PROJECT} + PUBLIC + eventsub + ) + set_target_directory_hierarchy(${EXECUTABLE_PROJECT}) if (WIN32) diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index 0505cdf8cd5..0af5fc36b22 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -10,6 +10,7 @@ #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/EventSub.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -142,75 +143,9 @@ QString forceImageUnload(const CommandContext &ctx) QString debugEventSub(const CommandContext &ctx) { - if (ctx.words.size() < 2) - { - ctx.channel->addMessage(makeSystemMessage("missing session ID")); - return {}; - } - - const auto &sessionID = ctx.words[1]; - - const auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - ctx.channel->addMessage( - makeSystemMessage("you must be logged in to use this command")); - return {}; - } + static EventSub eventSub; - auto sourceUserID = currentUser->getUserId(); - - getApp()->twitch->forEachChannelAndSpecialChannels( - [sessionID, sourceUserID](const ChannelPtr &channel) { - if (channel->getType() == Channel::Type::Twitch) - { - auto *twitchChannel = - dynamic_cast(channel.get()); - - auto roomID = twitchChannel->roomId(); - - if (channel->isBroadcaster()) - { - QJsonObject condition; - condition.insert("broadcaster_user_id", roomID); - - getHelix()->createEventSubSubscription( - "channel.ban", "1", sessionID, condition, - [roomID](const auto &response) { - qDebug() << "Successfully subscribed to " - "channel.ban in" - << roomID << ":" << response; - }, - [roomID](auto error, const auto &message) { - (void)error; - qDebug() << "Failed subscription to channel.ban in" - << roomID << ":" << message; - }); - } - - { - QJsonObject condition; - condition.insert("broadcaster_user_id", roomID); - condition.insert("user_id", sourceUserID); - - getHelix()->createEventSubSubscription( - "channel.chat.notification", "beta", sessionID, - condition, - [roomID](const auto &response) { - qDebug() << "Successfully subscribed to " - "channel.chat.notification in " - << roomID << ":" << response; - }, - [roomID](auto error, const auto &message) { - (void)error; - qDebug() << "Failed subscription to " - "channel.chat.notification in" - << roomID << ":" << message; - }); - } - } - }); + eventSub.start(); return ""; } diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp new file mode 100644 index 00000000000..d971c255dcf --- /dev/null +++ b/src/providers/twitch/EventSub.cpp @@ -0,0 +1,349 @@ +#include "providers/twitch/EventSub.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "eventsub/listener.hpp" +#include "eventsub/payloads/channel-ban-v1.hpp" +#include "eventsub/payloads/session-welcome.hpp" +#include "eventsub/session.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "util/PostToThread.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace beast = boost::beast; // from +namespace http = beast::http; // from +namespace websocket = beast::websocket; // from +namespace ssl = boost::asio::ssl; // from + +using boost::asio::awaitable; +using boost::asio::co_spawn; +using boost::asio::detached; +using boost::asio::use_awaitable; +using boost::asio::ip::tcp; +// using namespace boost::asio::experimental::awaitable_operators; +using namespace std::literals::chrono_literals; + +using WebSocketStream = websocket::stream>; + +namespace chatterino { + +using namespace eventsub; + +// Report a failure +void fail(beast::error_code ec, char const *what) +{ + std::cerr << what << ": " << ec.message() << "\n"; +} + +awaitable session(WebSocketStream &ws, std::unique_ptr listener) +{ + // start reader + std::cout << "start reader\n"; + co_await (sessionReader(ws, std::move(listener))); + // co_spawn(ws.get_executor(), sessionReader(ws), detached); + std::cout << "reader stopped\n"; + + co_return; +} + +class MyListener final : public Listener +{ +public: + void onSessionWelcome(messages::Metadata metadata, + payload::session_welcome::Payload payload) override + { + (void)metadata; + std::cout << "ON session welcome " << payload.id << " XD\n"; + + auto sessionID = QString::fromStdString(payload.id); + + const auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + return; + } + + auto sourceUserID = currentUser->getUserId(); + + getApp()->twitch->forEachChannelAndSpecialChannels( + [sessionID, sourceUserID](const ChannelPtr &channel) { + if (channel->getType() == Channel::Type::Twitch) + { + auto *twitchChannel = + dynamic_cast(channel.get()); + + auto roomID = twitchChannel->roomId(); + + if (channel->isBroadcaster()) + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + + getHelix()->createEventSubSubscription( + "channel.ban", "1", sessionID, condition, + [roomID](const auto &response) { + qDebug() << "Successfully subscribed to " + "channel.ban in" + << roomID << ":" << response; + }, + [roomID](auto error, const auto &message) { + (void)error; + qDebug() + << "Failed subscription to channel.ban in" + << roomID << ":" << message; + }); + } + + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + condition.insert("user_id", sourceUserID); + + getHelix()->createEventSubSubscription( + "channel.chat.notification", "beta", sessionID, + condition, + [roomID](const auto &response) { + qDebug() << "Successfully subscribed to " + "channel.chat.notification in " + << roomID << ":" << response; + }, + [roomID](auto error, const auto &message) { + (void)error; + qDebug() << "Failed subscription to " + "channel.chat.notification in" + << roomID << ":" << message; + }); + } + } + }); + } + + void onNotification(messages::Metadata metadata, + const boost::json::value &jv) override + { + (void)metadata; + std::cout << "on notification: " << jv << '\n'; + } + + void onChannelBan(messages::Metadata metadata, + payload::channel_ban::v1::Payload payload) override + { + (void)metadata; + std::cout << "Channel ban occured in " + << payload.event.broadcasterUserLogin << "'s channel:" + << " isPermanent=" << payload.event.isPermanent + << " reason=" << payload.event.reason + << " userLogin=" << payload.event.userLogin + << " moderatorLogin=" << payload.event.moderatorUserLogin + << '\n'; + } + + void onStreamOnline(messages::Metadata metadata, + payload::stream_online::v1::Payload payload) override + { + (void)metadata; + (void)payload; + std::cout << "ON STREAM ONLINE XD\n"; + } + + void onStreamOffline(messages::Metadata metadata, + payload::stream_offline::v1::Payload payload) override + { + (void)metadata; + (void)payload; + std::cout << "ON STREAM OFFLINE XD\n"; + } + + void onChannelChatNotification( + messages::Metadata metadata, + payload::channel_chat_notification::beta::Payload payload) override + { + (void)metadata; + (void)payload; + std::cout << "Received channel.chat.notification beta\n"; + } + + void onChannelUpdate(messages::Metadata metadata, + payload::channel_update::v1::Payload payload) override + { + (void)metadata; + (void)payload; + std::cout << "Channel update event!\n"; + } + + // Add your new subscription types above this line +}; + +awaitable connectToClient(boost::asio::io_context &ioContext, + const std::string host, const std::string port, + const std::string path, + boost::asio::ssl::context &sslContext) +{ + auto tcpResolver = tcp::resolver(ioContext); + + for (;;) + { + // TODO: wait on (AND INCREMENT) backoff timer + + boost::system::error_code resolveError; + auto target = co_await tcpResolver.async_resolve( + host, port, + boost::asio::redirect_error(boost::asio::use_awaitable, + resolveError)); + + std::cout << "Connecting to " << host << ":" << port << "\n"; + if (resolveError) + { + fail(resolveError, "resolve"); + continue; + } + + WebSocketStream ws(ioContext, sslContext); + + // Make the connection on the IP address we get from a lookup + // TODO: Check connectError + boost::system::error_code connectError; + auto endpoint = co_await beast::get_lowest_layer(ws).async_connect( + target, boost::asio::redirect_error(boost::asio::use_awaitable, + connectError)); + + std::string hostHeader{host}; + + // Set SNI Hostname (many hosts need this to handshake successfully) + if (!SSL_set_tlsext_host_name(ws.next_layer().native_handle(), + host.data())) + { + auto ec = beast::error_code(static_cast(::ERR_get_error()), + boost::asio::error::get_ssl_category()); + fail(ec, "connect"); + continue; + } + + // Update the host string. This will provide the value of the + // Host HTTP header during the WebSocket handshake. + // See https://tools.ietf.org/html/rfc7230#section-5.4 + hostHeader += ':' + std::to_string(endpoint.port()); + + // Set a timeout on the operation + beast::get_lowest_layer(ws).expires_after(std::chrono::seconds(30)); + + // Set a decorator to change the User-Agent of the handshake + ws.set_option( + websocket::stream_base::decorator([](websocket::request_type &req) { + req.set(http::field::user_agent, + std::string(BOOST_BEAST_VERSION_STRING) + + " websocket-client-coro"); + })); + + // Perform the SSL handshake + boost::system::error_code sslHandshakeError; + co_await ws.next_layer().async_handshake( + ssl::stream_base::client, + boost::asio::redirect_error(boost::asio::use_awaitable, + sslHandshakeError)); + if (sslHandshakeError) + { + fail(sslHandshakeError, "ssl_handshake"); + continue; + } + + // Turn off the timeout on the tcp_stream, because + // the websocket stream has its own timeout system. + beast::get_lowest_layer(ws).expires_never(); + + // Set suggested timeout settings for the websocket + ws.set_option(websocket::stream_base::timeout::suggested( + beast::role_type::client)); + + // Perform the websocket handshake + boost::system::error_code wsHandshakeError; + co_await ws.async_handshake( + hostHeader, path, + boost::asio::redirect_error(boost::asio::use_awaitable, + wsHandshakeError)); + if (wsHandshakeError) + { + fail(wsHandshakeError, "handshake"); + continue; + } + + std::unique_ptr listener = std::make_unique(); + co_await session(ws, std::move(listener)); + + // Close the WebSocket connection + boost::system::error_code closeError; + co_await ws.async_close(websocket::close_code::normal, + boost::asio::redirect_error( + boost::asio::use_awaitable, closeError)); + if (closeError) + { + fail(closeError, "close"); + } + else + { + // If we get here then the connection is closed gracefully + std::cout << "Closed connection gracefully\n"; + } + + // TODO: reset backoff timer + } +} + +void EventSub::start() +{ + // for use with twitch CLI: twitch event websocket start-server --ssl --port 3012 + // const auto *const host = "localhost"; + // const auto *const port = "3012"; + // const auto *const path = "/ws"; + + // for use with real Twitch eventsub + std::string host{"eventsub.wss.twitch.tv"}; + std::string port("443"); + std::string path("/ws"); + + try + { + this->mainThread = std::make_unique([=] { + boost::asio::io_context ctx(1); + + boost::asio::ssl::context sslContext{ + boost::asio::ssl::context::tlsv12_client}; + + // TODO: Load certificates into SSL context + + co_spawn(ctx, connectToClient(ctx, host, port, path, sslContext), + detached); + ctx.run(); + }); + } + catch (std::exception &e) + { + std::cerr << "Exception: " << e.what() << "\n"; + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/EventSub.hpp b/src/providers/twitch/EventSub.hpp new file mode 100644 index 00000000000..6fde6c54b8e --- /dev/null +++ b/src/providers/twitch/EventSub.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +namespace chatterino { + +class EventSub +{ +public: + void start(); + +private: + std::unique_ptr mainThread; +}; + +} // namespace chatterino From 5f08890308a20df5c7b67a0898c4a91c43978b30 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 11:20:33 +0100 Subject: [PATCH 05/23] fix: make `ChannelChatters::getUserColor` const --- src/common/ChannelChatters.cpp | 2 +- src/common/ChannelChatters.hpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index d65ae931b32..21befa412f4 100644 --- a/src/common/ChannelChatters.cpp +++ b/src/common/ChannelChatters.cpp @@ -89,7 +89,7 @@ size_t ChannelChatters::colorsSize() const return size; } -const QColor ChannelChatters::getUserColor(const QString &user) +const QColor ChannelChatters::getUserColor(const QString &user) const { const auto chatterColors = this->chatterColors_.access(); diff --git a/src/common/ChannelChatters.hpp b/src/common/ChannelChatters.hpp index b15717dc9d5..7c39621afcd 100644 --- a/src/common/ChannelChatters.hpp +++ b/src/common/ChannelChatters.hpp @@ -24,7 +24,7 @@ class ChannelChatters void addRecentChatter(const QString &user); void addJoinedUser(const QString &user); void addPartedUser(const QString &user); - const QColor getUserColor(const QString &user); + const QColor getUserColor(const QString &user) const; void setUserColor(const QString &user, const QColor &color); void updateOnlineChatters(const std::unordered_set &usernames); From b7d9a4874a38f06c2714898ff78ea8fdab6159c9 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 11:21:37 +0100 Subject: [PATCH 06/23] fix: make `TwitchChannel::cheerEmote` const --- src/providers/twitch/TwitchChannel.cpp | 2 +- src/providers/twitch/TwitchChannel.hpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index a8eace253ed..237123f8d9c 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1702,7 +1702,7 @@ std::optional TwitchChannel::ffzCustomVipBadge() const return this->ffzCustomVipBadge_.get(); } -std::optional TwitchChannel::cheerEmote(const QString &string) +std::optional TwitchChannel::cheerEmote(const QString &string) const { auto sets = this->cheerEmoteSets_.access(); for (const auto &set : *sets) diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index a331812af78..9ec4ddd342e 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -200,7 +200,7 @@ class TwitchChannel final : public Channel, public ChannelChatters const QString &version) const; // Cheers - std::optional cheerEmote(const QString &string); + std::optional cheerEmote(const QString &string) const; // Replies /** From 9399d4b35f782d7106af4d9d3b6af0b23dbd2003 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 12:35:53 +0100 Subject: [PATCH 07/23] clang-tidy: allow MOCK_METHOD function name --- .clang-tidy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.clang-tidy b/.clang-tidy index 170ad019a41..4108636bc72 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -40,7 +40,7 @@ CheckOptions: - key: readability-identifier-naming.FunctionCase value: camelBack - key: readability-identifier-naming.FunctionIgnoredRegexp - value: ^TEST$ + value: ^(TEST|MOCK_METHOD)$ - key: readability-identifier-naming.MemberCase value: camelBack From 41214cd53188d07829af9d382294f14032699ea4 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 14:35:20 +0100 Subject: [PATCH 08/23] Add license for date.h from https://github.com/HowardHinnant/date/blob/master/include/date/date.h --- resources/licenses/howard-hinnant-date.txt | 30 ++++++++++++++++++++++ src/widgets/settingspages/AboutPage.cpp | 3 +++ 2 files changed, 33 insertions(+) create mode 100644 resources/licenses/howard-hinnant-date.txt diff --git a/resources/licenses/howard-hinnant-date.txt b/resources/licenses/howard-hinnant-date.txt new file mode 100644 index 00000000000..efd8546777b --- /dev/null +++ b/resources/licenses/howard-hinnant-date.txt @@ -0,0 +1,30 @@ +The MIT License (MIT) + +Copyright (c) 2015, 2016, 2017 Howard Hinnant +Copyright (c) 2016 Adrian Colomitchi +Copyright (c) 2017 Florian Dang +Copyright (c) 2017 Paul Thompson +Copyright (c) 2018, 2019 Tomasz Kamiński +Copyright (c) 2019 Jiangang Zhuang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Our apologies. When the previous paragraph was written, lowercase had not yet +been invented (that would involve another several millennia of evolution). +We did not mean to shout. diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 471b6d03b25..ecb023ce492 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -126,6 +126,9 @@ AboutPage::AboutPage() addLicense(form.getElement(), "Fluent icons", "https://github.com/microsoft/fluentui-system-icons", ":/licenses/fluenticons.txt"); + addLicense(form.getElement(), "Howard Hinnant's date.h", + "https://github.com/microsoft/fluentui-system-icons", + ":/licenses/fluenticons.txt"); } // Attributions From d183f11fd69f1140126c99189d3744e2afb50845 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 18 Nov 2023 14:35:53 +0100 Subject: [PATCH 09/23] Add support for `channel.ban v1` to replace pubsub downside is it's broadcaster only, so it's not a good replacement yet --- src/providers/twitch/EventSub.cpp | 55 ++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index d971c255dcf..64d116d7773 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -6,7 +6,10 @@ #include "eventsub/payloads/channel-ban-v1.hpp" #include "eventsub/payloads/session-welcome.hpp" #include "eventsub/session.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -124,7 +127,7 @@ class MyListener final : public Listener condition.insert("user_id", sourceUserID); getHelix()->createEventSubSubscription( - "channel.chat.notification", "beta", sessionID, + "channel.chat.notification", "1", sessionID, condition, [roomID](const auto &response) { qDebug() << "Successfully subscribed to " @@ -159,7 +162,51 @@ class MyListener final : public Listener << " reason=" << payload.event.reason << " userLogin=" << payload.event.userLogin << " moderatorLogin=" << payload.event.moderatorUserLogin - << '\n'; + << " bannedAt=" << payload.event.bannedAt << '\n'; + + auto roomID = QString::fromStdString(payload.event.broadcasterUserID); + + BanAction action{ + // + }; + + action.timestamp = std::chrono::steady_clock::now(); + action.roomID = roomID; + action.source = ActionUser{ + .id = QString::fromStdString(payload.event.moderatorUserID), + .login = QString::fromStdString(payload.event.moderatorUserLogin), + .displayName = + QString::fromStdString(payload.event.moderatorUserName), + }; + action.target = ActionUser{ + .id = QString::fromStdString(payload.event.userID), + .login = QString::fromStdString(payload.event.userLogin), + .displayName = QString::fromStdString(payload.event.userName), + }; + action.reason = QString::fromStdString(payload.event.reason); + if (payload.event.isPermanent) + { + action.duration = 0; + } + else + { + auto timeoutDuration = payload.event.timeoutDuration(); + auto timeoutDurationInSeconds = + std::chrono::duration_cast( + timeoutDuration) + .count(); + action.duration = timeoutDurationInSeconds; + qDebug() << "TIMEOUT DURATION IN SECONDS: " + << timeoutDurationInSeconds; + } + + auto chan = getApp()->twitch->getChannelOrEmptyByID(roomID); + + runInGuiThread([action{std::move(action)}, chan{std::move(chan)}] { + MessageBuilder msg(action); + msg->flags.set(MessageFlag::PubSub); + chan->addOrReplaceTimeout(msg.release()); + }); } void onStreamOnline(messages::Metadata metadata, @@ -180,11 +227,11 @@ class MyListener final : public Listener void onChannelChatNotification( messages::Metadata metadata, - payload::channel_chat_notification::beta::Payload payload) override + payload::channel_chat_notification::v1::Payload payload) override { (void)metadata; (void)payload; - std::cout << "Received channel.chat.notification beta\n"; + std::cout << "Received channel.chat.notification v1\n"; } void onChannelUpdate(messages::Metadata metadata, From f7b12a9fb0ee78d02bd788cb9f37786c171763eb Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 19 Nov 2023 13:59:05 +0100 Subject: [PATCH 10/23] Add eventsub logging category --- src/common/QLogging.cpp | 2 ++ src/common/QLogging.hpp | 1 + 2 files changed, 3 insertions(+) diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index de4ef056c0c..59476d26b71 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -50,6 +50,8 @@ Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTwitchEventSub, "chatterino.twitch.eventsub", + logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitchLiveController, "chatterino.twitch.livecontroller", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 36daa0e1e92..82c55cbf16e 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -38,6 +38,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchEventSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchLiveController); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); From 8bb9be03df241d91fb35bd2ca96c245d57e1b379 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 19 Nov 2023 14:41:20 +0100 Subject: [PATCH 11/23] update for new eventsub lib version --- src/providers/twitch/EventSub.cpp | 291 ++++++++---------------------- 1 file changed, 72 insertions(+), 219 deletions(-) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index 64d116d7773..23c43ddd2db 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -1,11 +1,9 @@ #include "providers/twitch/EventSub.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" +#include "common/Version.hpp" #include "controllers/accounts/AccountController.hpp" -#include "eventsub/listener.hpp" -#include "eventsub/payloads/channel-ban-v1.hpp" -#include "eventsub/payloads/session-welcome.hpp" -#include "eventsub/session.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" @@ -15,71 +13,35 @@ #include "providers/twitch/TwitchIrcServer.hpp" #include "util/PostToThread.hpp" -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include +#include #include +#include +#include -#include #include -#include #include -#include - -namespace beast = boost::beast; // from -namespace http = beast::http; // from -namespace websocket = beast::websocket; // from -namespace ssl = boost::asio::ssl; // from - -using boost::asio::awaitable; -using boost::asio::co_spawn; -using boost::asio::detached; -using boost::asio::use_awaitable; -using boost::asio::ip::tcp; -// using namespace boost::asio::experimental::awaitable_operators; -using namespace std::literals::chrono_literals; -using WebSocketStream = websocket::stream>; +using namespace std::literals::chrono_literals; -namespace chatterino { +namespace { -using namespace eventsub; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoTwitchEventSub; -// Report a failure -void fail(beast::error_code ec, char const *what) -{ - std::cerr << what << ": " << ec.message() << "\n"; -} - -awaitable session(WebSocketStream &ws, std::unique_ptr listener) -{ - // start reader - std::cout << "start reader\n"; - co_await (sessionReader(ws, std::move(listener))); - // co_spawn(ws.get_executor(), sessionReader(ws), detached); - std::cout << "reader stopped\n"; +} // namespace - co_return; -} +namespace chatterino { -class MyListener final : public Listener +class MyListener final : public eventsub::Listener { public: - void onSessionWelcome(messages::Metadata metadata, - payload::session_welcome::Payload payload) override + void onSessionWelcome( + eventsub::messages::Metadata metadata, + eventsub::payload::session_welcome::Payload payload) override { (void)metadata; - std::cout << "ON session welcome " << payload.id << " XD\n"; + qCDebug(LOG) << "On session welcome:" << payload.id.c_str(); auto sessionID = QString::fromStdString(payload.id); @@ -145,30 +107,23 @@ class MyListener final : public Listener }); } - void onNotification(messages::Metadata metadata, + void onNotification(eventsub::messages::Metadata metadata, const boost::json::value &jv) override { (void)metadata; - std::cout << "on notification: " << jv << '\n'; + auto jsonString = boost::json::serialize(jv); + qCDebug(LOG) << "on notification: " << jsonString.c_str(); } - void onChannelBan(messages::Metadata metadata, - payload::channel_ban::v1::Payload payload) override + void onChannelBan( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_ban::v1::Payload payload) override { (void)metadata; - std::cout << "Channel ban occured in " - << payload.event.broadcasterUserLogin << "'s channel:" - << " isPermanent=" << payload.event.isPermanent - << " reason=" << payload.event.reason - << " userLogin=" << payload.event.userLogin - << " moderatorLogin=" << payload.event.moderatorUserLogin - << " bannedAt=" << payload.event.bannedAt << '\n'; auto roomID = QString::fromStdString(payload.event.broadcasterUserID); - BanAction action{ - // - }; + BanAction action{}; action.timestamp = std::chrono::steady_clock::now(); action.roomID = roomID; @@ -196,8 +151,6 @@ class MyListener final : public Listener timeoutDuration) .count(); action.duration = timeoutDurationInSeconds; - qDebug() << "TIMEOUT DURATION IN SECONDS: " - << timeoutDurationInSeconds; } auto chan = getApp()->twitch->getChannelOrEmptyByID(roomID); @@ -209,172 +162,70 @@ class MyListener final : public Listener }); } - void onStreamOnline(messages::Metadata metadata, - payload::stream_online::v1::Payload payload) override + void onStreamOnline( + eventsub::messages::Metadata metadata, + eventsub::payload::stream_online::v1::Payload payload) override { (void)metadata; - (void)payload; - std::cout << "ON STREAM ONLINE XD\n"; + qCDebug(LOG) << "On stream online event for channel" + << payload.event.broadcasterUserLogin.c_str(); } - void onStreamOffline(messages::Metadata metadata, - payload::stream_offline::v1::Payload payload) override + void onStreamOffline( + eventsub::messages::Metadata metadata, + eventsub::payload::stream_offline::v1::Payload payload) override { (void)metadata; - (void)payload; - std::cout << "ON STREAM OFFLINE XD\n"; + qCDebug(LOG) << "On stream offline event for channel" + << payload.event.broadcasterUserLogin.c_str(); } void onChannelChatNotification( - messages::Metadata metadata, - payload::channel_chat_notification::v1::Payload payload) override + eventsub::messages::Metadata metadata, + eventsub::payload::channel_chat_notification::v1::Payload payload) + override { (void)metadata; - (void)payload; - std::cout << "Received channel.chat.notification v1\n"; + qCDebug(LOG) << "On channel chat notification for" + << payload.event.broadcasterUserLogin.c_str(); } - void onChannelUpdate(messages::Metadata metadata, - payload::channel_update::v1::Payload payload) override + void onChannelUpdate( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_update::v1::Payload payload) override { (void)metadata; - (void)payload; - std::cout << "Channel update event!\n"; + qCDebug(LOG) << "On channel update for" + << payload.event.broadcasterUserLogin.c_str(); } - - // Add your new subscription types above this line }; -awaitable connectToClient(boost::asio::io_context &ioContext, - const std::string host, const std::string port, - const std::string path, - boost::asio::ssl::context &sslContext) -{ - auto tcpResolver = tcp::resolver(ioContext); - - for (;;) - { - // TODO: wait on (AND INCREMENT) backoff timer - - boost::system::error_code resolveError; - auto target = co_await tcpResolver.async_resolve( - host, port, - boost::asio::redirect_error(boost::asio::use_awaitable, - resolveError)); - - std::cout << "Connecting to " << host << ":" << port << "\n"; - if (resolveError) - { - fail(resolveError, "resolve"); - continue; - } - - WebSocketStream ws(ioContext, sslContext); - - // Make the connection on the IP address we get from a lookup - // TODO: Check connectError - boost::system::error_code connectError; - auto endpoint = co_await beast::get_lowest_layer(ws).async_connect( - target, boost::asio::redirect_error(boost::asio::use_awaitable, - connectError)); - - std::string hostHeader{host}; - - // Set SNI Hostname (many hosts need this to handshake successfully) - if (!SSL_set_tlsext_host_name(ws.next_layer().native_handle(), - host.data())) - { - auto ec = beast::error_code(static_cast(::ERR_get_error()), - boost::asio::error::get_ssl_category()); - fail(ec, "connect"); - continue; - } - - // Update the host string. This will provide the value of the - // Host HTTP header during the WebSocket handshake. - // See https://tools.ietf.org/html/rfc7230#section-5.4 - hostHeader += ':' + std::to_string(endpoint.port()); - - // Set a timeout on the operation - beast::get_lowest_layer(ws).expires_after(std::chrono::seconds(30)); - - // Set a decorator to change the User-Agent of the handshake - ws.set_option( - websocket::stream_base::decorator([](websocket::request_type &req) { - req.set(http::field::user_agent, - std::string(BOOST_BEAST_VERSION_STRING) + - " websocket-client-coro"); - })); - - // Perform the SSL handshake - boost::system::error_code sslHandshakeError; - co_await ws.next_layer().async_handshake( - ssl::stream_base::client, - boost::asio::redirect_error(boost::asio::use_awaitable, - sslHandshakeError)); - if (sslHandshakeError) - { - fail(sslHandshakeError, "ssl_handshake"); - continue; - } - - // Turn off the timeout on the tcp_stream, because - // the websocket stream has its own timeout system. - beast::get_lowest_layer(ws).expires_never(); - - // Set suggested timeout settings for the websocket - ws.set_option(websocket::stream_base::timeout::suggested( - beast::role_type::client)); - - // Perform the websocket handshake - boost::system::error_code wsHandshakeError; - co_await ws.async_handshake( - hostHeader, path, - boost::asio::redirect_error(boost::asio::use_awaitable, - wsHandshakeError)); - if (wsHandshakeError) - { - fail(wsHandshakeError, "handshake"); - continue; - } - - std::unique_ptr listener = std::make_unique(); - co_await session(ws, std::move(listener)); - - // Close the WebSocket connection - boost::system::error_code closeError; - co_await ws.async_close(websocket::close_code::normal, - boost::asio::redirect_error( - boost::asio::use_awaitable, closeError)); - if (closeError) - { - fail(closeError, "close"); - } - else - { - // If we get here then the connection is closed gracefully - std::cout << "Closed connection gracefully\n"; - } - - // TODO: reset backoff timer - } -} - void EventSub::start() { + const auto userAgent = QStringLiteral("chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toUtf8() + .toStdString(); + // for use with twitch CLI: twitch event websocket start-server --ssl --port 3012 - // const auto *const host = "localhost"; - // const auto *const port = "3012"; - // const auto *const path = "/ws"; + // std::string host{"localhost"}; + // std::string port{"3012"}; + // std::string path{"/ws"}; + + // for use with websocat: websocat -s 8080 --pkcs12-der certificate.p12 + // std::string host{"localhost"}; + // std::string port{"8080"}; + // std::string path; // for use with real Twitch eventsub std::string host{"eventsub.wss.twitch.tv"}; - std::string port("443"); - std::string path("/ws"); + std::string port{"443"}; + std::string path{"/ws"}; - try - { - this->mainThread = std::make_unique([=] { + this->mainThread = std::make_unique([=] { + try + { boost::asio::io_context ctx(1); boost::asio::ssl::context sslContext{ @@ -382,15 +233,17 @@ void EventSub::start() // TODO: Load certificates into SSL context - co_spawn(ctx, connectToClient(ctx, host, port, path, sslContext), - detached); + std::make_shared(ctx, sslContext, + std::make_unique()) + ->run(host, port, path, userAgent); + ctx.run(); - }); - } - catch (std::exception &e) - { - std::cerr << "Exception: " << e.what() << "\n"; - } + } + catch (std::exception &e) + { + qCWarning(LOG) << "Error in EventSub run thread" << e.what(); + } + }); } } // namespace chatterino From da79753229ef515b8a176840ba34cb71e3492514 Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 21 Nov 2023 09:05:10 +0100 Subject: [PATCH 12/23] Fix date.h license link Co-authored-by: Wissididom <30803034+Wissididom@users.noreply.github.com> --- src/widgets/settingspages/AboutPage.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index ecb023ce492..7cdaad6411a 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -127,8 +127,8 @@ AboutPage::AboutPage() "https://github.com/microsoft/fluentui-system-icons", ":/licenses/fluenticons.txt"); addLicense(form.getElement(), "Howard Hinnant's date.h", - "https://github.com/microsoft/fluentui-system-icons", - ":/licenses/fluenticons.txt"); + "https://github.com/HowardHinnant/date", + ":/licenses/howard-hinnant-date.txt"); } // Attributions From 9bf5cfe3b7b5f1adf49352f182fa165aff41a60e Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 27 Jan 2024 00:18:52 +0100 Subject: [PATCH 13/23] fix for application changes --- src/providers/twitch/EventSub.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index 23c43ddd2db..3c9248ef703 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -45,7 +45,7 @@ class MyListener final : public eventsub::Listener auto sessionID = QString::fromStdString(payload.id); - const auto currentUser = getApp()->accounts->twitch.getCurrent(); + const auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { From 2db096d59e48d79434eb51c76575435e6b0ad68b Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 27 Jan 2024 00:39:35 +0100 Subject: [PATCH 14/23] add eventsub over chat shitty code & proper submodule --- .gitmodules | 3 + CMakeLists.txt | 4 +- lib/twitch-eventsub-ws | 1 + src/CMakeLists.txt | 10 +- src/providers/twitch/EventSub.cpp | 46 +- .../twitch/EventSubMessageBuilder.cpp | 1353 +++++++++++++++++ .../twitch/EventSubMessageBuilder.hpp | 107 ++ src/providers/twitch/TwitchChannel.hpp | 1 + 8 files changed, 1515 insertions(+), 10 deletions(-) create mode 160000 lib/twitch-eventsub-ws create mode 100644 src/providers/twitch/EventSubMessageBuilder.cpp create mode 100644 src/providers/twitch/EventSubMessageBuilder.hpp diff --git a/.gitmodules b/.gitmodules index cb1235a8582..d801c4c73ff 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,6 @@ [submodule "tools/crash-handler"] path = tools/crash-handler url = https://github.com/Chatterino/crash-handler +[submodule "lib/twitch-eventsub-ws"] + path = lib/twitch-eventsub-ws + url = https://github.com/Chatterino/twitch-eventsub-ws diff --git a/CMakeLists.txt b/CMakeLists.txt index 78a943c1abb..4a848cb3614 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -204,6 +204,8 @@ else() add_subdirectory("${CMAKE_SOURCE_DIR}/lib/settings" EXCLUDE_FROM_ALL) endif() +add_subdirectory("${CMAKE_SOURCE_DIR}/lib/twitch-eventsub-ws" EXCLUDE_FROM_ALL) + if (CHATTERINO_PLUGINS) set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") add_subdirectory(lib/lua) @@ -213,8 +215,6 @@ if (BUILD_WITH_CRASHPAD) add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler") endif() -add_subdirectory("${CMAKE_SOURCE_DIR}/../beast-websocket-client" eventsub EXCLUDE_FROM_ALL) - # Used to provide a date of build in the About page (for nightly builds). Getting the actual time of # compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. # For CI runs, however, the date of build file generation should be consistent with the date of diff --git a/lib/twitch-eventsub-ws b/lib/twitch-eventsub-ws new file mode 160000 index 00000000000..091301e15d2 --- /dev/null +++ b/lib/twitch-eventsub-ws @@ -0,0 +1 @@ +Subproject commit 091301e15d2aa86b5218c1917e351026aaf1d544 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 910c65dae16..ac1544c6018 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -374,6 +374,8 @@ set(SOURCE_FILES providers/twitch/ChannelPointReward.hpp providers/twitch/EventSub.cpp providers/twitch/EventSub.hpp + providers/twitch/EventSubMessageBuilder.cpp + providers/twitch/EventSubMessageBuilder.hpp providers/twitch/IrcMessageHandler.cpp providers/twitch/IrcMessageHandler.hpp providers/twitch/PubSubActions.cpp @@ -766,11 +768,7 @@ target_link_libraries(${LIBRARY_PROJECT} RapidJSON::RapidJSON LRUCache MagicEnum - ) - -target_link_libraries(${LIBRARY_PROJECT} - PUBLIC - eventsub + twitch-eventsub-ws ) if (CHATTERINO_PLUGINS) @@ -824,7 +822,7 @@ if (BUILD_APP) target_link_libraries(${EXECUTABLE_PROJECT} PUBLIC - eventsub + twitch-eventsub-ws ) set_target_directory_hierarchy(${EXECUTABLE_PROJECT}) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index 3c9248ef703..0130bdef174 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -7,6 +7,7 @@ #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/EventSubMessageBuilder.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" @@ -16,8 +17,8 @@ #include #include #include -#include -#include +#include +#include #include #include @@ -103,6 +104,26 @@ class MyListener final : public eventsub::Listener << roomID << ":" << message; }); } + + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + condition.insert("user_id", sourceUserID); + + getHelix()->createEventSubSubscription( + "channel.chat.message", "v1", sessionID, condition, + [roomID](const auto &response) { + qDebug() << "Successfully subscribed to " + "channel.chat.message in " + << roomID << ":" << response; + }, + [roomID](auto error, const auto &message) { + (void)error; + qDebug() << "Failed subscription to " + "channel.chat.message in" + << roomID << ":" << message; + }); + } } }); } @@ -198,6 +219,27 @@ class MyListener final : public eventsub::Listener qCDebug(LOG) << "On channel update for" << payload.event.broadcasterUserLogin.c_str(); } + + void onChannelChatMessage( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_chat_message::v1::Payload payload) override + { + (void)metadata; + + std::cout << "Channel chat message event!\n"; + + runInGuiThread([payload{std::move(payload)}]() { + MessageParseArgs args; + EventSubMessageBuilder builder(payload, args); + + auto message = builder.build(); + + auto channel = getApp()->twitch->getChannelOrEmptyByID( + QString::fromStdString(payload.event.broadcasterUserID)); + + channel->addMessage(message); + }); + } }; void EventSub::start() diff --git a/src/providers/twitch/EventSubMessageBuilder.cpp b/src/providers/twitch/EventSubMessageBuilder.cpp new file mode 100644 index 00000000000..bf7b1765369 --- /dev/null +++ b/src/providers/twitch/EventSubMessageBuilder.cpp @@ -0,0 +1,1353 @@ +#include "providers/twitch/EventSubMessageBuilder.hpp" + +#include "Application.hpp" +#include "common/LinkParser.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/ignores/IgnoreController.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "controllers/userdata/UserDataController.hpp" +#include "messages/Emote.hpp" +#include "messages/Image.hpp" +#include "messages/Message.hpp" +#include "messages/MessageThread.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/chatterino/ChatterinoBadges.hpp" +#include "providers/colors/ColorProvider.hpp" +#include "providers/ffz/FfzBadges.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/ChannelPointReward.hpp" +#include "providers/twitch/PubSubActions.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/TwitchBadges.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" +#include "util/Helpers.hpp" +#include "util/IrcHelpers.hpp" +#include "util/QStringHash.hpp" +#include "util/Qt.hpp" +#include "widgets/Window.hpp" + +#include +#include +#include + +#include +#include + +using namespace chatterino::literals; + +namespace { + +using namespace chatterino; +using namespace std::chrono_literals; + +const QString regexHelpString("(\\w+)[.,!?;:]*?$"); + +// matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" +const QRegularExpression mentionRegex("^@" + regexHelpString); + +// if findAllUsernames setting is enabled, matches strings like in the examples above, but without @ symbol at the beginning +const QRegularExpression allUsernamesMentionRegex("^" + regexHelpString); + +const QSet zeroWidthEmotes{ + "SoSnowy", "IceCold", "SantaHat", "TopHat", + "ReinDeer", "CandyCane", "cvMask", "cvHazmat", +}; + +struct HypeChatPaidLevel { + std::chrono::seconds duration; + uint8_t numeric; +}; + +const std::unordered_map HYPE_CHAT_PAID_LEVEL{ + {u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}}, + {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}}, + {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}}, + {u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}}, + {u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}}, +}; + +bool doesWordContainATwitchEmote( + int cursor, const QString &word, + const std::vector &twitchEmotes, + std::vector::const_iterator ¤tTwitchEmoteIt) +{ + if (currentTwitchEmoteIt == twitchEmotes.end()) + { + // No emote to add! + return false; + } + + const auto ¤tTwitchEmote = *currentTwitchEmoteIt; + + auto wordEnd = cursor + word.length(); + + // Check if this emote fits within the word boundaries + if (currentTwitchEmote.start < cursor || currentTwitchEmote.end > wordEnd) + { + // this emote does not fit xd + return false; + } + + return true; +} + +} // namespace + +namespace chatterino { + +namespace { + + void appendTwitchEmoteOccurrences(const QString &emote, + std::vector &vec, + const std::vector &correctPositions, + const QString &originalMessage, + int messageOffset) + { + auto *app = getIApp(); + if (!emote.contains(':')) + { + return; + } + + auto parameters = emote.split(':'); + + if (parameters.length() < 2) + { + return; + } + + auto id = EmoteId{parameters.at(0)}; + + auto occurrences = parameters.at(1).split(','); + + for (const QString &occurrence : occurrences) + { + auto coords = occurrence.split('-'); + + if (coords.length() < 2) + { + return; + } + + auto from = coords.at(0).toUInt() - messageOffset; + auto to = coords.at(1).toUInt() - messageOffset; + auto maxPositions = correctPositions.size(); + if (from > to || to >= maxPositions) + { + // Emote coords are out of range + qCDebug(chatterinoTwitch) + << "Emote coords" << from << "-" << to + << "are out of range (" << maxPositions << ")"; + return; + } + + auto start = correctPositions[from]; + auto end = correctPositions[to]; + if (start > end || start < 0 || end > originalMessage.length()) + { + // Emote coords are out of range from the modified character positions + qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to + << "are out of range after offsets (" + << originalMessage.length() << ")"; + return; + } + + auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; + TwitchEmoteOccurrence emoteOccurrence{ + start, + end, + app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), + name, + }; + if (emoteOccurrence.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "nullptr" << emoteOccurrence.name.string; + } + vec.push_back(std::move(emoteOccurrence)); + } + } + +} // namespace + +EventSubMessageBuilder::EventSubMessageBuilder( + const eventsub::payload::channel_chat_message::v1::Payload &_payload, + const MessageParseArgs &_args) + : payload(_payload) + , channel(getApp()->twitch->getChannelOrEmptyByID( + QString::fromStdString(this->payload.event.broadcasterUserID))) + , args(_args) + , originalMessage(QString::fromStdString(this->payload.event.message.text)) + , twitchChannel(dynamic_cast(this->channel.get())) +{ +} + +MessagePtr EventSubMessageBuilder::build() +{ + this->emplace("ES", MessageElementFlag::Text, + MessageColor::Text); + // Parse sender + this->userId_ = QString::fromStdString(this->payload.event.chatterUserID); + this->userName = + QString::fromStdString(this->payload.event.chatterUserLogin); + this->message().loginName = this->userName; + QString displayName = + QString::fromStdString(this->payload.event.chatterUserName).trimmed(); + if (QString::compare(displayName, this->userName, Qt::CaseInsensitive) == 0) + { + this->message().displayName = displayName; + } + else + { + this->message().displayName = this->userName; + this->message().localizedName = displayName; + } + + // Parse channel + this->roomID_ = + QString::fromStdString(this->payload.event.broadcasterUserID); + + this->parseUsernameColor(); + + // TODO: stylize + + if (this->userName == this->channel->getName()) + { + this->senderIsBroadcaster = true; + } + + this->message().channelName = this->channel->getName(); + + this->message().id = QString::fromStdString(this->payload.event.messageID); + + // TODO: Handle channel point reward, since it must be appended before any other element + + { + // appendChannelName + QString channelName("#" + this->channel->getName()); + Link link(Link::JumpToChannel, this->channel->getName()); + + this->emplace(channelName, MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink(link); + } + + if (this->tags.contains("rm-deleted")) + { + this->message().flags.set(MessageFlag::Disabled); + } + + if (this->tags.contains("msg-id") && + this->tags["msg-id"].toString().split(';').contains( + "highlighted-message")) + { + this->message().flags.set(MessageFlag::RedeemedHighlight); + } + + if (this->tags.contains("first-msg") && + this->tags["first-msg"].toString() == "1") + { + this->message().flags.set(MessageFlag::FirstMessage); + } + + if (this->tags.contains("pinned-chat-paid-amount")) + { + this->message().flags.set(MessageFlag::ElevatedMessage); + } + + if (this->tags.contains("bits")) + { + this->message().flags.set(MessageFlag::CheerMessage); + } + + // reply threads + this->parseThread(); + + // timestamp + // TODO: No server received time available + this->message().serverReceivedTime = QDateTime::currentDateTime(); + this->emplace(this->message().serverReceivedTime.time()); + + if (this->shouldAddModerationElements()) + { + this->emplace(); + } + + this->appendTwitchBadges(); + + this->appendChatterinoBadges(); + this->appendFfzBadges(); + this->appendSeventvBadges(); + + qDebug() << "username:" << this->userName; + + // TODO: stylized username + this->emplace(this->userName, MessageElementFlag::Username, + this->usernameColor, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, this->message().displayName}); + + // QString bits; + auto iterator = this->tags.find("bits"); + if (iterator != this->tags.end()) + { + this->hasBits_ = true; + this->bitsLeft = iterator.value().toInt(); + this->bits = iterator.value().toString(); + } + + // words + this->addWords(); + + // stylizeUsername + auto localizedName = + QString::fromStdString(this->payload.event.chatterUserName); + bool hasLocalizedName = !localizedName.isEmpty(); + + // The full string that will be rendered in the chat widget + QString stylizedUsername; + + switch (getSettings()->usernameDisplayMode.getValue()) + { + case UsernameDisplayMode::Username: { + stylizedUsername = this->userName; + } + break; + + case UsernameDisplayMode::LocalizedName: { + if (hasLocalizedName) + { + stylizedUsername = localizedName; + } + else + { + stylizedUsername = this->userName; + } + } + break; + + default: + case UsernameDisplayMode::UsernameAndLocalizedName: { + if (hasLocalizedName) + { + stylizedUsername = this->userName + "(" + localizedName + ")"; + } + else + { + stylizedUsername = this->userName; + } + } + break; + } + + if (auto nicknameText = getSettings()->matchNickname(stylizedUsername)) + { + stylizedUsername = *nicknameText; + } + + this->message().messageText = this->originalMessage; + this->message().searchText = stylizedUsername + " " + + this->message().localizedName + " " + + this->userName + ": " + this->originalMessage; + + // TODO: highlights + // this->parseHighlights(); + + // highlighting incoming whispers if requested per setting + if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) + { + this->message().flags.set(MessageFlag::HighlightedWhisper, true); + this->message().highlightColor = + ColorProvider::instance().color(ColorType::Whisper); + } + + if (this->thread_) + { + auto &img = getResources().buttons.replyThreadDark; + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else + { + auto &img = getResources().buttons.replyDark; + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ReplyToMessage, this->message().id}); + } + + return this->release(); +} + +void EventSubMessageBuilder::addTextOrEmoji(EmotePtr emote) +{ + MessageBuilder::addTextOrEmoji(emote); +} + +void EventSubMessageBuilder::addTextOrEmoji(const QString &string_) +{ + auto string = QString(string_); + + // TODO: Handle cheermote? + + // TODO: Implement ignored emotes + // Format of ignored emotes: + // Emote name: "forsenPuke" - if string in ignoredEmotes + // Will match emote regardless of source (i.e. bttv, ffz) + // Emote source + name: "bttv:nyanPls" + if (this->tryAppendEmote({string})) + { + // Successfully appended an emote + return; + } + + // Actually just text + LinkParser parsed(string); + auto textColor = this->textColor_; + + if (parsed.result()) + { + this->addLink(*parsed.result()); + return; + } + + if (string.startsWith('@')) + { + auto match = mentionRegex.match(string); + // Only treat as @mention if valid username + if (match.hasMatch()) + { + QString username = match.captured(1); + auto originalTextColor = textColor; + + if (this->twitchChannel != nullptr && getSettings()->colorUsernames) + { + if (auto userColor = + this->twitchChannel->getUserColor(username); + userColor.isValid()) + { + textColor = userColor; + } + } + + auto prefixedUsername = '@' + username; + this->emplace(prefixedUsername, + MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, username}) + ->setTrailingSpace(false); + + this->emplace(prefixedUsername, + MessageElementFlag::NonBoldUsername, + textColor) + ->setLink({Link::UserInfo, username}) + ->setTrailingSpace(false); + + this->emplace(string.remove(prefixedUsername), + MessageElementFlag::Text, + originalTextColor); + + return; + } + } + + if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) + { + auto match = allUsernamesMentionRegex.match(string); + QString username = match.captured(1); + + if (match.hasMatch() && + this->twitchChannel->accessChatters()->contains(username)) + { + auto originalTextColor = textColor; + + if (getSettings()->colorUsernames) + { + if (auto userColor = + this->twitchChannel->getUserColor(username); + userColor.isValid()) + { + textColor = userColor; + } + } + + this->emplace(username, + MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, username}) + ->setTrailingSpace(false); + + this->emplace( + username, MessageElementFlag::NonBoldUsername, textColor) + ->setLink({Link::UserInfo, username}) + ->setTrailingSpace(false); + + this->emplace(string.remove(username), + MessageElementFlag::Text, + originalTextColor); + + return; + } + } + + this->emplace(string, MessageElementFlag::Text, textColor); +} + +void EventSubMessageBuilder::addWords() +{ + qDebug() << "addWords"; + for (const auto &fragment : this->payload.event.message.fragments) + { + // We can trim the string here since we add a space between elements by default + auto text = QString::fromStdString(fragment.text).trimmed(); + qDebug() << "XXX: Fragment:" << text; + + if (fragment.type == "text") + { + auto words = text.split(' '); + + for (const auto &word : words) + { + if (word.isEmpty()) + { + continue; + } + + // split words + for (auto &variant : + getIApp()->getEmotes()->getEmojis()->parse(word)) + { + boost::apply_visitor( + [&](auto &&arg) { + this->addTextOrEmoji(arg); + }, + variant); + } + } + } + else if (fragment.type == "emote") + { + // Twitch emote + if (fragment.emote.has_value()) + { + const auto &emote = *fragment.emote; + auto emoteID = QString::fromStdString(emote.id); + const auto &emoteName = text; + + auto emoteImage = + getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{emoteID}, EmoteName{emoteName}); + + this->emplace(emoteImage, + MessageElementFlag::TwitchEmote, + MessageColor::Text); + } + else + { + qDebug() << "EMOTE TYPE BUT NO EMOTE??"; + } + } + else + { + qDebug() << "XXX: Unhandled fragment type" + << QString::fromStdString(fragment.type); + this->emplace(text, MessageElementFlag::Text, + MessageColor::Text); + } + } +} + +void EventSubMessageBuilder::parseThread() +{ + if (this->thread_) + { + // set references + this->message().replyThread = this->thread_; + this->message().replyParent = this->parent_; + this->thread_->addToThread(this->weakOf()); + + // enable reply flag + this->message().flags.set(MessageFlag::ReplyMessage); + + MessagePtr threadRoot; + if (!this->parent_) + { + threadRoot = this->thread_->root(); + } + else + { + threadRoot = this->parent_; + } + + QString usernameText = SharedMessageBuilder::stylizeUsername( + threadRoot->loginName, *threadRoot); + + this->emplace(); + + // construct reply elements + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + + this->emplace( + "@" + usernameText + ":", MessageElementFlag::RepliedMessage, + threadRoot->usernameColor, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, threadRoot->displayName}); + + this->emplace( + threadRoot->messageText, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) + { + // Message is a reply but we couldn't find the original message. + // Render the message using the additional reply tags + + auto replyDisplayName = this->tags.find("reply-parent-display-name"); + auto replyBody = this->tags.find("reply-parent-msg-body"); + + if (replyDisplayName != this->tags.end() && + replyBody != this->tags.end()) + { + QString body; + + this->emplace(); + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall); + + // TODO + if (false /*this->isIgnoredReply()*/) + { + body = QString("[Blocked user]"); + } + else + { + auto name = replyDisplayName->toString(); + body = parseTagString(replyBody->toString()); + + this->emplace( + "@" + name + ":", MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, name}); + } + + this->emplace( + body, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), + this->textColor_, FontStyle::ChatMediumSmall); + } + } +} + +void EventSubMessageBuilder::parseUsernameColor() +{ + const auto *userData = getIApp()->getUserData(); + assert(userData != nullptr); + + if (const auto &user = userData->getUser(this->userId_)) + { + if (user->color) + { + this->usernameColor = user->color.value(); + return; + } + } + + if (!this->payload.event.color.empty()) + { + this->usernameColor = + QColor(QString::fromStdString(this->payload.event.color)); + this->message().usernameColor = this->usernameColor; + return; + } + + if (getSettings()->colorizeNicknames) + { + this->usernameColor = getRandomColor(this->userId_); + this->message().usernameColor = this->usernameColor; + } +} + +void EventSubMessageBuilder::runIgnoreReplaces( + std::vector &twitchEmotes) +{ + auto phrases = getSettings()->ignoredMessages.readOnly(); + auto removeEmotesInRange = [](int pos, int len, + auto &twitchEmotes) mutable { + auto it = std::partition( + twitchEmotes.begin(), twitchEmotes.end(), + [pos, len](const auto &item) { + return !((item.start >= pos) && item.start < (pos + len)); + }); + for (auto copy = it; copy != twitchEmotes.end(); ++copy) + { + if ((*copy).ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "remem nullptr" << (*copy).name.string; + } + } + std::vector v(it, twitchEmotes.end()); + twitchEmotes.erase(it, twitchEmotes.end()); + return v; + }; + + auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) mutable { + for (auto &item : twitchEmotes) + { + auto &index = item.start; + if (index >= pos) + { + index += by; + item.end += by; + } + } + }; + + auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, + const auto &midrepl, + int startIndex) mutable { + if (!phrase.containsEmote()) + { + return; + } + + auto words = midrepl.split(' '); + int pos = 0; + for (const auto &word : words) + { + for (const auto &emote : phrase.getEmotes()) + { + if (word == emote.first.string) + { + if (emote.second == nullptr) + { + qCDebug(chatterinoTwitch) + << "emote null" << emote.first.string; + } + twitchEmotes.push_back(TwitchEmoteOccurrence{ + startIndex + pos, + startIndex + pos + (int)emote.first.string.length(), + emote.second, + emote.first, + }); + } + } + pos += word.length() + 1; + } + }; + + for (const auto &phrase : *phrases) + { + if (phrase.isBlock()) + { + continue; + } + const auto &pattern = phrase.getPattern(); + if (pattern.isEmpty()) + { + continue; + } + if (phrase.isRegex()) + { + const auto ®ex = phrase.getRegex(); + if (!regex.isValid()) + { + continue; + } + QRegularExpressionMatch match; + int from = 0; + while ((from = this->originalMessage.indexOf(regex, from, + &match)) != -1) + { + int len = match.capturedLength(); + auto vret = removeEmotesInRange(from, len, twitchEmotes); + auto mid = this->originalMessage.mid(from, len); + mid.replace(regex, phrase.getReplace()); + + int midsize = mid.size(); + this->originalMessage.replace(from, len, mid); + int pos1 = from; + while (pos1 > 0) + { + if (this->originalMessage[pos1 - 1] == ' ') + { + break; + } + --pos1; + } + int pos2 = from + midsize; + while (pos2 < this->originalMessage.length()) + { + if (this->originalMessage[pos2] == ' ') + { + break; + } + ++pos2; + } + + shiftIndicesAfter(from + len, midsize - len); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + auto midExtendedRef = + QStringView{this->originalMessage}.mid(pos1, pos2 - pos1); +#else + auto midExtendedRef = + this->originalMessage.midRef(pos1, pos2 - pos1); +#endif + + for (auto &tup : vret) + { + if (tup.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "v nullptr" << tup.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + tup.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); + auto _match = emoteregex.match(midExtendedRef); + if (_match.hasMatch()) + { + int last = _match.lastCapturedIndex(); + for (int i = 0; i <= last; ++i) + { + tup.start = from + _match.capturedStart(); + twitchEmotes.push_back(std::move(tup)); + } + } + } + + addReplEmotes(phrase, midExtendedRef, pos1); + + from += midsize; + } + } + else + { + int from = 0; + while ((from = this->originalMessage.indexOf( + pattern, from, phrase.caseSensitivity())) != -1) + { + int len = pattern.size(); + auto vret = removeEmotesInRange(from, len, twitchEmotes); + auto replace = phrase.getReplace(); + + int replacesize = replace.size(); + this->originalMessage.replace(from, len, replace); + + int pos1 = from; + while (pos1 > 0) + { + if (this->originalMessage[pos1 - 1] == ' ') + { + break; + } + --pos1; + } + int pos2 = from + replacesize; + while (pos2 < this->originalMessage.length()) + { + if (this->originalMessage[pos2] == ' ') + { + break; + } + ++pos2; + } + + shiftIndicesAfter(from + len, replacesize - len); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + auto midExtendedRef = + QStringView{this->originalMessage}.mid(pos1, pos2 - pos1); +#else + auto midExtendedRef = + this->originalMessage.midRef(pos1, pos2 - pos1); +#endif + + for (auto &tup : vret) + { + if (tup.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "v nullptr" << tup.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + tup.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); + auto match = emoteregex.match(midExtendedRef); + if (match.hasMatch()) + { + int last = match.lastCapturedIndex(); + for (int i = 0; i <= last; ++i) + { + tup.start = from + match.capturedStart(); + twitchEmotes.push_back(std::move(tup)); + } + } + } + + addReplEmotes(phrase, midExtendedRef, pos1); + + from += replacesize; + } + } + } +} + +Outcome EventSubMessageBuilder::tryAppendEmote(const EmoteName &name) +{ + auto *app = getIApp(); + + const auto &globalBttvEmotes = app->getBttvEmotes(); + const auto &globalFfzEmotes = app->getFfzEmotes(); + const auto &globalSeventvEmotes = app->getSeventvEmotes(); + + auto flags = MessageElementFlags(); + auto emote = std::optional{}; + bool zeroWidth = false; + + // Emote order: + // - FrankerFaceZ Channel + // - BetterTTV Channel + // - 7TV Channel + // - FrankerFaceZ Global + // - BetterTTV Global + // - 7TV Global + if (this->twitchChannel && (emote = this->twitchChannel->ffzEmote(name))) + { + flags = MessageElementFlag::FfzEmote; + } + else if (this->twitchChannel && + (emote = this->twitchChannel->bttvEmote(name))) + { + flags = MessageElementFlag::BttvEmote; + } + else if (this->twitchChannel != nullptr && + (emote = this->twitchChannel->seventvEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + zeroWidth = emote.value()->zeroWidth; + } + else if ((emote = globalFfzEmotes->emote(name))) + { + flags = MessageElementFlag::FfzEmote; + } + else if ((emote = globalBttvEmotes->emote(name))) + { + flags = MessageElementFlag::BttvEmote; + zeroWidth = zeroWidthEmotes.contains(name.string); + } + else if ((emote = globalSeventvEmotes->globalEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + zeroWidth = emote.value()->zeroWidth; + } + + if (emote) + { + if (zeroWidth && getSettings()->enableZeroWidthEmotes && + !this->isEmpty()) + { + // Attempt to merge current zero-width emote into any previous emotes + auto *asEmote = dynamic_cast(&this->back()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = { + {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; + this->emplace( + std::move(layers), baseEmoteElement->getFlags() | flags, + this->textColor_); + return Success; + } + + auto *asLayered = + dynamic_cast(&this->back()); + if (asLayered) + { + asLayered->addEmoteLayer({*emote, flags}); + asLayered->addFlags(flags); + return Success; + } + + // No emote to merge with, just show as regular emote + } + + this->emplace(*emote, flags, this->textColor_); + return Success; + } + + return Failure; +} + +std::optional EventSubMessageBuilder::getTwitchBadge( + const Badge &badge) const +{ + if (auto channelBadge = + this->twitchChannel->twitchBadge(badge.key_, badge.value_)) + { + return channelBadge; + } + + if (auto globalBadge = + getIApp()->getTwitchBadges()->badge(badge.key_, badge.value_)) + { + return globalBadge; + } + + return std::nullopt; +} + +void EventSubMessageBuilder::appendTwitchBadges() +{ + if (this->twitchChannel == nullptr) + { + return; + } + + for (const auto &rawBadge : this->payload.event.badges) + { + const auto key = QString::fromStdString(rawBadge.setID); + const auto value = QString::fromStdString(rawBadge.id); + + Badge badge(key, value); + + auto badgeEmote = this->getTwitchBadge(badge); + if (!badgeEmote) + { + continue; + } + auto tooltip = (*badgeEmote)->tooltip.string; + + if (badge.key_ == "bits") + { + const auto &cheerAmount = badge.value_; + tooltip = QString("Twitch cheer %0").arg(cheerAmount); + } + else if (badge.key_ == "moderator" && + getSettings()->useCustomFfzModeratorBadges) + { + if (auto customModBadge = this->twitchChannel->ffzCustomModBadge()) + { + this->emplace( + *customModBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customModBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges) + { + if (auto customVipBadge = this->twitchChannel->ffzCustomVipBadge()) + { + this->emplace( + *customVipBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customVipBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.flag_ == MessageElementFlag::BadgeSubscription) + { + if (!rawBadge.info.empty()) + { + // badge.value_ is 4 chars long if user is subbed on higher tier + // (tier + amount of months with leading zero if less than 100) + // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; + const auto subMonths = QString::fromStdString(rawBadge.info); + tooltip += + QString(" (%1%2 months)") + .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) + : "") + .arg(subMonths); + } + } + else if (badge.flag_ == MessageElementFlag::BadgePredictions) + { + if (!rawBadge.info.empty()) + { + auto info = QString::fromStdString(rawBadge.info); + // TODO: is this replace necessary for eventsub stuff? + auto predictionText = + info.replace(R"(\s)", " ") // standard IRC escapes + .replace(R"(\:)", ";") + .replace(R"(\\)", R"(\)") + .replace("⸝", ","); // twitch's comma escape + // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma + + tooltip = QString("Predicted %1").arg(predictionText); + } + } + + this->emplace(*badgeEmote, badge.flag_) + ->setTooltip(tooltip); + } + + // TODO: implement + /* + auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); + auto badges = SharedMessageBuilder::parseBadgeTag(this->tags); + + for (const auto &badge : badges) + { + auto badgeEmote = this->getTwitchBadge(badge); + if (!badgeEmote) + { + continue; + } + auto tooltip = (*badgeEmote)->tooltip.string; + + if (badge.key_ == "bits") + { + const auto &cheerAmount = badge.value_; + tooltip = QString("Twitch cheer %0").arg(cheerAmount); + } + else if (badge.key_ == "moderator" && + getSettings()->useCustomFfzModeratorBadges) + { + if (auto customModBadge = this->twitchChannel->ffzCustomModBadge()) + { + this->emplace( + *customModBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customModBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges) + { + if (auto customVipBadge = this->twitchChannel->ffzCustomVipBadge()) + { + this->emplace( + *customVipBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customVipBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.flag_ == MessageElementFlag::BadgeSubscription) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + // badge.value_ is 4 chars long if user is subbed on higher tier + // (tier + amount of months with leading zero if less than 100) + // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; + const auto &subMonths = badgeInfoIt->second; + tooltip += + QString(" (%1%2 months)") + .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) + : "") + .arg(subMonths); + } + } + else if (badge.flag_ == MessageElementFlag::BadgePredictions) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + auto predictionText = + badgeInfoIt->second + .replace(R"(\s)", " ") // standard IRC escapes + .replace(R"(\:)", ";") + .replace(R"(\\)", R"(\)") + .replace("⸝", ","); // twitch's comma escape + // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma + + tooltip = QString("Predicted %1").arg(predictionText); + } + } + + this->emplace(*badgeEmote, badge.flag_) + ->setTooltip(tooltip); + } + + this->message().badges = badges; + this->message().badgeInfos = badgeInfos; + */ +} + +void EventSubMessageBuilder::appendChatterinoBadges() +{ + if (auto badge = + getIApp()->getChatterinoBadges()->getBadge({this->userId_})) + { + this->emplace(*badge, + MessageElementFlag::BadgeChatterino); + } +} + +void EventSubMessageBuilder::appendFfzBadges() +{ + for (const auto &badge : + getIApp()->getFfzBadges()->getUserBadges({this->userId_})) + { + this->emplace( + badge.emote, MessageElementFlag::BadgeFfz, badge.color); + } +} + +void EventSubMessageBuilder::appendSeventvBadges() +{ + if (auto badge = getIApp()->getSeventvBadges()->getBadge({this->userId_})) + { + this->emplace(*badge, MessageElementFlag::BadgeSevenTV); + } +} + +Outcome EventSubMessageBuilder::tryParseCheermote(const QString &string) +{ + if (this->bitsLeft == 0) + { + return Failure; + } + + auto cheerOpt = this->twitchChannel->cheerEmote(string); + + if (!cheerOpt) + { + return Failure; + } + + auto &cheerEmote = *cheerOpt; + auto match = cheerEmote.regex.match(string); + + if (!match.hasMatch()) + { + return Failure; + } + + int cheerValue = match.captured(1).toInt(); + + if (getSettings()->stackBits) + { + if (this->bitsStacked) + { + return Success; + } + if (cheerEmote.staticEmote) + { + this->emplace(cheerEmote.staticEmote, + MessageElementFlag::BitsStatic, + this->textColor_); + } + if (cheerEmote.animatedEmote) + { + this->emplace(cheerEmote.animatedEmote, + MessageElementFlag::BitsAnimated, + this->textColor_); + } + if (cheerEmote.color != QColor()) + { + this->emplace(QString::number(this->bitsLeft), + MessageElementFlag::BitsAmount, + cheerEmote.color); + } + this->bitsStacked = true; + return Success; + } + + if (this->bitsLeft >= cheerValue) + { + this->bitsLeft -= cheerValue; + } + else + { + QString newString = string; + newString.chop(QString::number(cheerValue).length()); + newString += QString::number(cheerValue - this->bitsLeft); + + return tryParseCheermote(newString); + } + + if (cheerEmote.staticEmote) + { + this->emplace(cheerEmote.staticEmote, + MessageElementFlag::BitsStatic, + this->textColor_); + } + if (cheerEmote.animatedEmote) + { + this->emplace(cheerEmote.animatedEmote, + MessageElementFlag::BitsAnimated, + this->textColor_); + } + if (cheerEmote.color != QColor()) + { + this->emplace(match.captured(1), + MessageElementFlag::BitsAmount, + cheerEmote.color); + } + + return Success; +} + +bool EventSubMessageBuilder::shouldAddModerationElements() const +{ + if (this->senderIsBroadcaster) + { + // You cannot timeout the broadcaster + return false; + } + + if (this->tags.value("user-type").toString() == "mod" && + !this->args.isStaffOrBroadcaster) + { + // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel + return false; + } + + return true; +} + +void EventSubMessageBuilder::setThread(std::shared_ptr thread) +{ + this->thread_ = std::move(thread); +} + +void EventSubMessageBuilder::setParent(MessagePtr parent) +{ + this->parent_ = std::move(parent); +} + +void EventSubMessageBuilder::setMessageOffset(int offset) +{ + this->messageOffset_ = offset; +} + +} // namespace chatterino diff --git a/src/providers/twitch/EventSubMessageBuilder.hpp b/src/providers/twitch/EventSubMessageBuilder.hpp new file mode 100644 index 00000000000..61f07136e24 --- /dev/null +++ b/src/providers/twitch/EventSubMessageBuilder.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include "common/Aliases.hpp" +#include "common/Outcome.hpp" +#include "messages/SharedMessageBuilder.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" // TODO: REMOVE + +#include +#include +#include + +#include +#include + +namespace chatterino { + +struct Emote; +using EmotePtr = std::shared_ptr; + +class Channel; +class TwitchChannel; +class MessageThread; +struct HelixVip; +using HelixModerator = HelixVip; +struct ChannelPointReward; +struct DeleteAction; + +class EventSubMessageBuilder : MessageBuilder +{ + const eventsub::payload::channel_chat_message::v1::Payload &payload; + const std::shared_ptr channel; + const MessageParseArgs &args; + QString originalMessage; + const TwitchChannel *twitchChannel; + + QColor usernameColor = {153, 153, 153}; + +public: + EventSubMessageBuilder() = delete; + + /** + * NOTE: The builder MUST NOT survive longer than the payload + **/ + explicit EventSubMessageBuilder( + const eventsub::payload::channel_chat_message::v1::Payload &_payload, + const MessageParseArgs &_args); + + ~EventSubMessageBuilder() override = default; + + MessagePtr build(); + + void setThread(std::shared_ptr thread); + void setParent(MessagePtr parent); + void setMessageOffset(int offset); + +private: + void parseUsernameColor(); + // Parse & build thread information into the message + // Will read information from thread_ or from IRC tags + void parseThread(); + + void runIgnoreReplaces(std::vector &twitchEmotes); + + std::optional getTwitchBadge(const Badge &badge) const; + Outcome tryAppendEmote(const EmoteName &name); + + void addWords(); + void addTextOrEmoji(EmotePtr emote) override; + void addTextOrEmoji(const QString &value) override; + + void appendTwitchBadges(); + void appendChatterinoBadges(); + void appendFfzBadges(); + void appendSeventvBadges(); + Outcome tryParseCheermote(const QString &string); + + bool shouldAddModerationElements() const; + + QString roomID_; + bool hasBits_ = false; + QString bits; + int bitsLeft{}; + bool bitsStacked = false; + std::shared_ptr thread_; + MessagePtr parent_; + + /** + * Starting offset to be used on index-based operations on `originalMessage_`. + * + * For example: + * originalMessage_ = "there" + * messageOffset_ = 4 + * (the irc message is "hey there") + * + * then the index 6 would resolve to 6 - 4 = 2 => 'e' + */ + int messageOffset_ = 0; + + // User ID of the sender of this message + QString userId_; + QString userName; // TODO: Rename to userLogin + bool senderIsBroadcaster{}; + + const QVariantMap tags; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 9ec4ddd342e..2c44cde6278 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -451,6 +451,7 @@ class TwitchChannel final : public Channel, public ChannelChatters friend class TwitchIrcServer; friend class TwitchMessageBuilder; + friend class EventSubMessageBuilder; // TODO: Remove this friend class IrcMessageHandler; }; From 71bd1ed2a9e31ea4b89ae5935faa95d13e57d679 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 27 Jan 2024 00:45:17 +0100 Subject: [PATCH 15/23] fix channel.chat.message subscription version --- src/providers/twitch/EventSub.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index 0130bdef174..f40c6961d19 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -111,7 +111,7 @@ class MyListener final : public eventsub::Listener condition.insert("user_id", sourceUserID); getHelix()->createEventSubSubscription( - "channel.chat.message", "v1", sessionID, condition, + "channel.chat.message", "1", sessionID, condition, [roomID](const auto &response) { qDebug() << "Successfully subscribed to " "channel.chat.message in " From 23d4fb27276dc67497d65239566ceaf9ed12f44e Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 27 Jan 2024 00:54:56 +0100 Subject: [PATCH 16/23] update `twitch-eventsub-ws` version --- lib/twitch-eventsub-ws | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/twitch-eventsub-ws b/lib/twitch-eventsub-ws index 091301e15d2..94c62d07fae 160000 --- a/lib/twitch-eventsub-ws +++ b/lib/twitch-eventsub-ws @@ -1 +1 @@ -Subproject commit 091301e15d2aa86b5218c1917e351026aaf1d544 +Subproject commit 94c62d07fae0d0126dd34540fc971a68a628c4aa From aa16d87d6cb6bc3164eea21b442a23ff4404dbae Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 28 Jan 2024 13:10:53 +0100 Subject: [PATCH 17/23] fix: use header-only boost Co-Authored-By: nerix --- lib/twitch-eventsub-ws | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/twitch-eventsub-ws b/lib/twitch-eventsub-ws index 94c62d07fae..dca1597753a 160000 --- a/lib/twitch-eventsub-ws +++ b/lib/twitch-eventsub-ws @@ -1 +1 @@ -Subproject commit 94c62d07fae0d0126dd34540fc971a68a628c4aa +Subproject commit dca1597753a751b5c4c886b27c6fb699061d4419 From 08d8d26e925d83f465f4c388b7cfb5a912020396 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 25 Feb 2024 13:09:34 +0100 Subject: [PATCH 18/23] nit: remove const in return value of ChannelChatters::getUserColor UNRELATED CHANGE :-) --- src/common/ChannelChatters.cpp | 4 ++-- src/common/ChannelChatters.hpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index 21befa412f4..df09c2f8eb6 100644 --- a/src/common/ChannelChatters.cpp +++ b/src/common/ChannelChatters.cpp @@ -89,7 +89,7 @@ size_t ChannelChatters::colorsSize() const return size; } -const QColor ChannelChatters::getUserColor(const QString &user) const +QColor ChannelChatters::getUserColor(const QString &user) const { const auto chatterColors = this->chatterColors_.access(); @@ -98,7 +98,7 @@ const QColor ChannelChatters::getUserColor(const QString &user) const if (!chatterColors->exists(lowerUser)) { // Returns an invalid color so we can decide not to override `textColor` - return QColor(); + return {}; } return QColor::fromRgb(chatterColors->get(lowerUser)); diff --git a/src/common/ChannelChatters.hpp b/src/common/ChannelChatters.hpp index 7c39621afcd..cd4408f0800 100644 --- a/src/common/ChannelChatters.hpp +++ b/src/common/ChannelChatters.hpp @@ -24,7 +24,7 @@ class ChannelChatters void addRecentChatter(const QString &user); void addJoinedUser(const QString &user); void addPartedUser(const QString &user); - const QColor getUserColor(const QString &user) const; + QColor getUserColor(const QString &user) const; void setUserColor(const QString &user, const QColor &color); void updateOnlineChatters(const std::unordered_set &usernames); From 7d2e8171a993b23a1856e4981b72134e2c68fc50 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 25 Feb 2024 13:11:31 +0100 Subject: [PATCH 19/23] clang-tidy: unused ctx param --- src/controllers/commands/builtin/chatterino/Debugging.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index 0af5fc36b22..f65627e9afb 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -143,6 +143,8 @@ QString forceImageUnload(const CommandContext &ctx) QString debugEventSub(const CommandContext &ctx) { + (void)ctx; + static EventSub eventSub; eventSub.start(); From b7a3215e90fa35c48464dd18b386d61ea5b8124b Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 25 Feb 2024 13:12:14 +0100 Subject: [PATCH 20/23] remove todo comment --- src/providers/twitch/TwitchChannel.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 207a7cec5db..cd6b83a7fab 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -462,7 +462,7 @@ class TwitchChannel final : public Channel, public ChannelChatters friend class TwitchIrcServer; friend class TwitchMessageBuilder; - friend class EventSubMessageBuilder; // TODO: Remove this + friend class EventSubMessageBuilder; friend class IrcMessageHandler; }; From 8ec40f9293364a33957aa5e95b280c15afa85e8a Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 25 Feb 2024 13:39:49 +0100 Subject: [PATCH 21/23] Move TwitchEmoteOccurrence to TwitchCommon.hpp --- .../twitch/EventSubMessageBuilder.hpp | 7 ++++++- src/providers/twitch/TwitchCommon.hpp | 18 ++++++++++++++++++ src/providers/twitch/TwitchMessageBuilder.hpp | 14 +------------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/providers/twitch/EventSubMessageBuilder.hpp b/src/providers/twitch/EventSubMessageBuilder.hpp index 61f07136e24..74b95a502eb 100644 --- a/src/providers/twitch/EventSubMessageBuilder.hpp +++ b/src/providers/twitch/EventSubMessageBuilder.hpp @@ -3,7 +3,7 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" #include "messages/SharedMessageBuilder.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" // TODO: REMOVE +#include "providers/twitch/TwitchCommon.hpp" #include #include @@ -38,6 +38,11 @@ class EventSubMessageBuilder : MessageBuilder public: EventSubMessageBuilder() = delete; + EventSubMessageBuilder(const EventSubMessageBuilder &) = delete; + EventSubMessageBuilder &operator=(const EventSubMessageBuilder &) = delete; + + EventSubMessageBuilder(EventSubMessageBuilder &&) = delete; + EventSubMessageBuilder &operator=(EventSubMessageBuilder &&) = delete; /** * NOTE: The builder MUST NOT survive longer than the payload **/ diff --git a/src/providers/twitch/TwitchCommon.hpp b/src/providers/twitch/TwitchCommon.hpp index 19f538c2a4f..b14db841026 100644 --- a/src/providers/twitch/TwitchCommon.hpp +++ b/src/providers/twitch/TwitchCommon.hpp @@ -1,5 +1,7 @@ #pragma once +#include "common/Aliases.hpp" + #include #include @@ -7,6 +9,9 @@ namespace chatterino { +struct Emote; +using EmotePtr = std::shared_ptr; + #ifndef ATTR_UNUSED # ifdef Q_OS_WIN # define ATTR_UNUSED @@ -81,4 +86,17 @@ static const QStringList TWITCH_DEFAULT_COMMANDS{ static const QStringList TWITCH_WHISPER_COMMANDS{"/w", ".w"}; +struct TwitchEmoteOccurrence { + int start; + int end; + EmotePtr ptr; + EmoteName name; + + bool operator==(const TwitchEmoteOccurrence &other) const + { + return std::tie(this->start, this->end, this->ptr, this->name) == + std::tie(other.start, other.end, other.ptr, other.name); + } +}; + } // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index dd38fc79078..48784e3ed28 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -3,6 +3,7 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" #include "messages/SharedMessageBuilder.hpp" +#include "providers/twitch/TwitchCommon.hpp" #include "pubsubmessages/LowTrustUsers.hpp" #include @@ -26,19 +27,6 @@ using HelixModerator = HelixVip; struct ChannelPointReward; struct DeleteAction; -struct TwitchEmoteOccurrence { - int start; - int end; - EmotePtr ptr; - EmoteName name; - - bool operator==(const TwitchEmoteOccurrence &other) const - { - return std::tie(this->start, this->end, this->ptr, this->name) == - std::tie(other.start, other.end, other.ptr, other.name); - } -}; - class TwitchMessageBuilder : public SharedMessageBuilder { public: From 38560ba6662697034d8d58510d3f8fa0ef0626b1 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 25 Feb 2024 14:36:16 +0100 Subject: [PATCH 22/23] Enable SSL certificate verification --- .gitmodules | 3 +++ CMakeLists.txt | 1 + cmake/FindBoostCertify.cmake | 14 +++++++++++ lib/certify | 1 + src/CMakeLists.txt | 1 + src/providers/twitch/EventSub.cpp | 42 ++++++++++++++++++++----------- 6 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 cmake/FindBoostCertify.cmake create mode 160000 lib/certify diff --git a/.gitmodules b/.gitmodules index d801c4c73ff..e0527ae00e6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,6 @@ [submodule "lib/twitch-eventsub-ws"] path = lib/twitch-eventsub-ws url = https://github.com/Chatterino/twitch-eventsub-ws +[submodule "lib/certify"] + path = lib/certify + url = https://github.com/Chatterino/certify diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ae2fcca9e9..1b22aa50c8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,7 @@ if (BUILD_BENCHMARKS) endif () find_package(PajladaSerialize REQUIRED) +find_package(BoostCertify REQUIRED) find_package(PajladaSignals REQUIRED) find_package(LRUCache REQUIRED) find_package(MagicEnum REQUIRED) diff --git a/cmake/FindBoostCertify.cmake b/cmake/FindBoostCertify.cmake new file mode 100644 index 00000000000..7dc04c3a801 --- /dev/null +++ b/cmake/FindBoostCertify.cmake @@ -0,0 +1,14 @@ +include(FindPackageHandleStandardArgs) + +find_path(BoostCertify_INCLUDE_DIR boost/certify/https_verification.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/certify/include) + +find_package_handle_standard_args(BoostCertify DEFAULT_MSG BoostCertify_INCLUDE_DIR) + +if (BoostCertify_FOUND) + add_library(BoostCertify INTERFACE IMPORTED) + set_target_properties(BoostCertify PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${BoostCertify_INCLUDE_DIR}" + ) +endif () + +mark_as_advanced(BoostCertify_INCLUDE_DIR) diff --git a/lib/certify b/lib/certify new file mode 160000 index 00000000000..a448a3915dd --- /dev/null +++ b/lib/certify @@ -0,0 +1 @@ +Subproject commit a448a3915ddac716ce76e4b8cbf0e7f4153ed1e2 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0a8bb770b65..753f0423275 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -775,6 +775,7 @@ target_link_libraries(${LIBRARY_PROJECT} LRUCache MagicEnum twitch-eventsub-ws + BoostCertify ) if (CHATTERINO_PLUGINS) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index f40c6961d19..12ff08dd15c 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -16,6 +16,8 @@ #include #include +#include +#include #include #include #include @@ -27,6 +29,20 @@ using namespace std::literals::chrono_literals; namespace { +/// Enable LOCAL_EVENTSUB when you want to debug eventsub with a local instance of the Twitch CLI +/// twitch event websocket start-server --ssl --port 3012 +constexpr bool LOCAL_EVENTSUB = false; + +std::tuple getEventSubHost() +{ + if constexpr (LOCAL_EVENTSUB) + { + return {"localhost", "3012", "/ws"}; + } + + return {"eventsub.wss.twitch.tv", "443", "/ws"}; +} + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) const auto &LOG = chatterinoTwitchEventSub; @@ -250,20 +266,7 @@ void EventSub::start() .toUtf8() .toStdString(); - // for use with twitch CLI: twitch event websocket start-server --ssl --port 3012 - // std::string host{"localhost"}; - // std::string port{"3012"}; - // std::string path{"/ws"}; - - // for use with websocat: websocat -s 8080 --pkcs12-der certificate.p12 - // std::string host{"localhost"}; - // std::string port{"8080"}; - // std::string path; - - // for use with real Twitch eventsub - std::string host{"eventsub.wss.twitch.tv"}; - std::string port{"443"}; - std::string path{"/ws"}; + auto [host, port, path] = getEventSubHost(); this->mainThread = std::make_unique([=] { try @@ -273,7 +276,16 @@ void EventSub::start() boost::asio::ssl::context sslContext{ boost::asio::ssl::context::tlsv12_client}; - // TODO: Load certificates into SSL context + if constexpr (!LOCAL_EVENTSUB) + { + sslContext.set_verify_mode( + boost::asio::ssl::verify_peer | + boost::asio::ssl::verify_fail_if_no_peer_cert); + sslContext.set_default_verify_paths(); + + boost::certify::enable_native_https_server_verification( + sslContext); + } std::make_shared(ctx, sslContext, std::make_unique()) From 0e9c193ce9e5cfdb8ecbf4d5e2d6e989a337538f Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 2 Mar 2024 22:01:13 +0100 Subject: [PATCH 23/23] fix(eventsub): compilation issues (#5212) * fix(eventsub): support all compilers and stuff * fix: awaitable * fix: qualify `value_to` This is the run --- CMakeLists.txt | 1 + lib/twitch-eventsub-ws | 2 +- src/CMakeLists.txt | 5 ----- src/providers/twitch/EventSub.cpp | 4 +++- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1b22aa50c8f..b413322d35f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -209,6 +209,7 @@ else() add_subdirectory("${CMAKE_SOURCE_DIR}/lib/settings" EXCLUDE_FROM_ALL) endif() +set(TWITCH_EVENTSUB_WS_LIBRARY_TYPE STATIC) add_subdirectory("${CMAKE_SOURCE_DIR}/lib/twitch-eventsub-ws" EXCLUDE_FROM_ALL) if (CHATTERINO_PLUGINS) diff --git a/lib/twitch-eventsub-ws b/lib/twitch-eventsub-ws index dca1597753a..12f831efefd 160000 --- a/lib/twitch-eventsub-ws +++ b/lib/twitch-eventsub-ws @@ -1 +1 @@ -Subproject commit dca1597753a751b5c4c886b27c6fb699061d4419 +Subproject commit 12f831efefda61a36f0cd68e6eba74302ea4e6f1 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 753f0423275..6c6c798340f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -834,11 +834,6 @@ if (BUILD_APP) target_link_libraries(${EXECUTABLE_PROJECT} PUBLIC ${LIBRARY_PROJECT}) - target_link_libraries(${EXECUTABLE_PROJECT} - PUBLIC - twitch-eventsub-ws - ) - set_target_directory_hierarchy(${EXECUTABLE_PROJECT}) if (WIN32) diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp index 12ff08dd15c..6943f0c542d 100644 --- a/src/providers/twitch/EventSub.cpp +++ b/src/providers/twitch/EventSub.cpp @@ -266,11 +266,13 @@ void EventSub::start() .toUtf8() .toStdString(); - auto [host, port, path] = getEventSubHost(); + auto eventSubHost = getEventSubHost(); this->mainThread = std::make_unique([=] { try { + auto [host, port, path] = eventSubHost; + boost::asio::io_context ctx(1); boost::asio::ssl::context sslContext{