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 diff --git a/.gitmodules b/.gitmodules index cb1235a8582..e0527ae00e6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,9 @@ [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 +[submodule "lib/certify"] + path = lib/certify + url = https://github.com/Chatterino/certify diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b576a9e068..13d3c4414e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,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) diff --git a/CMakeLists.txt b/CMakeLists.txt index 14efcb0daf8..b413322d35f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,7 +127,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) @@ -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) @@ -208,6 +209,9 @@ 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) set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") add_subdirectory(lib/lua) 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/lib/twitch-eventsub-ws b/lib/twitch-eventsub-ws new file mode 160000 index 00000000000..12f831efefd --- /dev/null +++ b/lib/twitch-eventsub-ws @@ -0,0 +1 @@ +Subproject commit 12f831efefda61a36f0cd68e6eba74302ea4e6f1 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/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/CMakeLists.txt b/src/CMakeLists.txt index 8d1e4d05af0..6c6c798340f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -378,6 +378,10 @@ set(SOURCE_FILES providers/twitch/ChannelPointReward.cpp 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 @@ -770,7 +774,10 @@ target_link_libraries(${LIBRARY_PROJECT} RapidJSON::RapidJSON LRUCache MagicEnum + twitch-eventsub-ws + BoostCertify ) + if (CHATTERINO_PLUGINS) target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) endif() diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index d65ae931b32..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) +QColor ChannelChatters::getUserColor(const QString &user) const { const auto chatterColors = this->chatterColors_.access(); @@ -98,7 +98,7 @@ const QColor ChannelChatters::getUserColor(const QString &user) 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 b15717dc9d5..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); + QColor getUserColor(const QString &user) const; void setUserColor(const QString &user, const QColor &color); void updateOnlineChatters(const std::unordered_set &usernames); 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); 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..f65627e9afb 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -1,12 +1,19 @@ #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/EventSub.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 +141,15 @@ QString forceImageUnload(const CommandContext &ctx) return ""; } +QString debugEventSub(const CommandContext &ctx) +{ + (void)ctx; + + static EventSub eventSub; + + eventSub.start(); + + 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 diff --git a/src/providers/twitch/EventSub.cpp b/src/providers/twitch/EventSub.cpp new file mode 100644 index 00000000000..6943f0c542d --- /dev/null +++ b/src/providers/twitch/EventSub.cpp @@ -0,0 +1,305 @@ +#include "providers/twitch/EventSub.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "common/Version.hpp" +#include "controllers/accounts/AccountController.hpp" +#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" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "util/PostToThread.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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; + +} // namespace + +namespace chatterino { + +class MyListener final : public eventsub::Listener +{ +public: + void onSessionWelcome( + eventsub::messages::Metadata metadata, + eventsub::payload::session_welcome::Payload payload) override + { + (void)metadata; + qCDebug(LOG) << "On session welcome:" << payload.id.c_str(); + + auto sessionID = QString::fromStdString(payload.id); + + const auto currentUser = getIApp()->getAccounts()->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", "1", 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; + }); + } + + { + QJsonObject condition; + condition.insert("broadcaster_user_id", roomID); + condition.insert("user_id", sourceUserID); + + getHelix()->createEventSubSubscription( + "channel.chat.message", "1", 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; + }); + } + } + }); + } + + void onNotification(eventsub::messages::Metadata metadata, + const boost::json::value &jv) override + { + (void)metadata; + auto jsonString = boost::json::serialize(jv); + qCDebug(LOG) << "on notification: " << jsonString.c_str(); + } + + void onChannelBan( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_ban::v1::Payload payload) override + { + (void)metadata; + + 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; + } + + 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( + eventsub::messages::Metadata metadata, + eventsub::payload::stream_online::v1::Payload payload) override + { + (void)metadata; + qCDebug(LOG) << "On stream online event for channel" + << payload.event.broadcasterUserLogin.c_str(); + } + + void onStreamOffline( + eventsub::messages::Metadata metadata, + eventsub::payload::stream_offline::v1::Payload payload) override + { + (void)metadata; + qCDebug(LOG) << "On stream offline event for channel" + << payload.event.broadcasterUserLogin.c_str(); + } + + void onChannelChatNotification( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_chat_notification::v1::Payload payload) + override + { + (void)metadata; + qCDebug(LOG) << "On channel chat notification for" + << payload.event.broadcasterUserLogin.c_str(); + } + + void onChannelUpdate( + eventsub::messages::Metadata metadata, + eventsub::payload::channel_update::v1::Payload payload) override + { + (void)metadata; + 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() +{ + const auto userAgent = QStringLiteral("chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toUtf8() + .toStdString(); + + 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{ + boost::asio::ssl::context::tlsv12_client}; + + 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()) + ->run(host, port, path, userAgent); + + ctx.run(); + } + catch (std::exception &e) + { + qCWarning(LOG) << "Error in EventSub run thread" << e.what(); + } + }); +} + +} // 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 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..74b95a502eb --- /dev/null +++ b/src/providers/twitch/EventSubMessageBuilder.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include "common/Aliases.hpp" +#include "common/Outcome.hpp" +#include "messages/SharedMessageBuilder.hpp" +#include "providers/twitch/TwitchCommon.hpp" + +#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; + + 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 + **/ + 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.cpp b/src/providers/twitch/TwitchChannel.cpp index 162f9aebfd5..519cef7e58d 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1753,7 +1753,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 2add5430213..cd6b83a7fab 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -209,7 +209,7 @@ class TwitchChannel final : public Channel, public ChannelChatters std::vector ffzChannelBadges(const QString &userID) const; // Cheers - std::optional cheerEmote(const QString &string); + std::optional cheerEmote(const QString &string) const; // Replies /** @@ -462,6 +462,7 @@ class TwitchChannel final : public Channel, public ChannelChatters friend class TwitchIrcServer; friend class TwitchMessageBuilder; + friend class EventSubMessageBuilder; friend class IrcMessageHandler; }; 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: diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 1c5a0ee3f10..2fa54e67c44 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2840,6 +2840,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 500eba60a01..7a4d39e9561 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -714,6 +714,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: @@ -1027,6 +1086,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: @@ -1341,6 +1408,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(); diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 471b6d03b25..7cdaad6411a 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/HowardHinnant/date", + ":/licenses/howard-hinnant-date.txt"); } // Attributions