diff --git a/CHANGELOG.md b/CHANGELOG.md index 233941a1..c18fc99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ - Bugfix: Fixed color input thinking blue is also red. (#5982) - Bugfix: Fixed an issue where commands would sometimes reset if Chatterino was improperly shut down. (#6011) - Dev: Subscriptions to PubSub channel points redemption topics now use no auth token, making it continue to work during PubSub shutdown. (#5947) -- Dev: Add initial experimental EventSub support. (#5837, #5895, #5897, #5904, #5910, #5903, #5915, #5916, #5930, #5935, #5932, #5943, #5952, #5953, #5968, #5973, #5974, #5980, #5981, #5985, #5990, #5992, #5993, #5996, #5995, #6000, #6001, #6002, #6003, #6005, #6007, #6010) +- Dev: Add initial experimental EventSub support. (#5837, #5895, #5897, #5904, #5910, #5903, #5915, #5916, #5930, #5935, #5932, #5943, #5952, #5953, #5968, #5973, #5974, #5980, #5981, #5985, #5990, #5992, #5993, #5996, #5995, #6000, #6001, #6002, #6003, #6005, #6007, #6010, #6008) - Dev: Remove unneeded platform specifier for toasts. (#5914) - Dev: Highlight checks now use non-capturing groups for the boundaries. (#5784) - Dev: Removed unused PubSub whisper code. (#5898) diff --git a/lib/twitch-eventsub-ws/benchmarks/src/parse.cpp b/lib/twitch-eventsub-ws/benchmarks/src/parse.cpp index e23e4b99..240934fe 100644 --- a/lib/twitch-eventsub-ws/benchmarks/src/parse.cpp +++ b/lib/twitch-eventsub-ws/benchmarks/src/parse.cpp @@ -145,6 +145,24 @@ public: benchmark::DoNotOptimize(&metadata); benchmark::DoNotOptimize(&payload); } + + void onChannelChatUserMessageHold( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_hold::v1::Payload &payload) + override + { + benchmark::DoNotOptimize(&metadata); + benchmark::DoNotOptimize(&payload); + } + + void onChannelChatUserMessageUpdate( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_update::v1::Payload &payload) + override + { + benchmark::DoNotOptimize(&metadata); + benchmark::DoNotOptimize(&payload); + } // NOLINTEND(cppcoreguidelines-pro-type-const-cast) }; diff --git a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/listener.hpp b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/listener.hpp index 83963358..5a4f207b 100644 --- a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/listener.hpp +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/listener.hpp @@ -6,6 +6,8 @@ #include "twitch-eventsub-ws/payloads/channel-ban-v1.hpp" #include "twitch-eventsub-ws/payloads/channel-chat-message-v1.hpp" #include "twitch-eventsub-ws/payloads/channel-chat-notification-v1.hpp" +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp" +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp" #include "twitch-eventsub-ws/payloads/channel-moderate-v2.hpp" #include "twitch-eventsub-ws/payloads/channel-suspicious-user-message-v1.hpp" #include "twitch-eventsub-ws/payloads/channel-suspicious-user-update-v1.hpp" @@ -74,6 +76,16 @@ public: const messages::Metadata &metadata, const payload::channel_suspicious_user_update::v1::Payload &payload) = 0; + + virtual void onChannelChatUserMessageHold( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_hold::v1::Payload + &payload) = 0; + + virtual void onChannelChatUserMessageUpdate( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_update::v1::Payload + &payload) = 0; // Add your new subscription types above this line }; diff --git a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp new file mode 100644 index 00000000..153b7200 --- /dev/null +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "twitch-eventsub-ws/payloads/structured-message.hpp" +#include "twitch-eventsub-ws/payloads/subscription.hpp" +#include "twitch-eventsub-ws/string.hpp" + +#include + +namespace chatterino::eventsub::lib::payload::channel_chat_user_message_hold:: + v1 { + +// {"subscription":{"id":"94ab19f1-dd41-41e7-a5bc-f8f45bd4342a","status":"enabled","type":"channel.chat.user_message_hold","version":"1","condition":{"broadcaster_user_id":"117166826","user_id":"159849156"},"transport":{"method":"websocket","session_id":"AgoQP515xn0yRxGvyU29dY9grBIGY2VsbC1j"},"created_at":"2025-03-01T10:40:14.527982426Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","user_id":"159849156","user_login":"bajlada","user_name":"BajLada","message_id":"e39e3c58-3d25-49fd-8c94-e776ef57a7f8","message":{"text":"penis penis penis","fragments":[{"type":"text","text":"penis penis penis","cheermote":null,"emote":null}]}}} + +struct Event { + // Broadcaster of the channel the message was sent in + String broadcasterUserID; + String broadcasterUserLogin; + String broadcasterUserName; + + // User who sent the message + String userID; + String userLogin; + String userName; + + String messageID; + chat::Message message; +}; + +struct Payload { + subscription::Subscription subscription; + + Event event; +}; + +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.inc" + +} // namespace chatterino::eventsub::lib::payload::channel_chat_user_message_hold::v1 diff --git a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.inc b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.inc new file mode 100644 index 00000000..9a88467f --- /dev/null +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.inc @@ -0,0 +1,5 @@ +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag, const boost::json::value &jvRoot); + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag, const boost::json::value &jvRoot); diff --git a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp new file mode 100644 index 00000000..fb5ebcac --- /dev/null +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "twitch-eventsub-ws/payloads/structured-message.hpp" +#include "twitch-eventsub-ws/payloads/subscription.hpp" +#include "twitch-eventsub-ws/string.hpp" + +#include + +namespace chatterino::eventsub::lib::payload::channel_chat_user_message_update:: + v1 { + +// message approved: +// {"subscription":{"id":"3a9fd3ec-f5b5-49d7-8624-2195b3ebfda9","status":"enabled","type":"channel.chat.user_message_update","version":"1","condition":{"broadcaster_user_id":"117166826","user_id":"159849156"},"transport":{"method":"websocket","session_id":"AgoQBFnyoXrLQxiCmkHTvJ8VNBIGY2VsbC1j"},"created_at":"2025-03-01T11:29:49.532375428Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","user_id":"159849156","user_login":"bajlada","user_name":"BajLada","status":"approved","message_id":"5686ec52-e02c-4042-ac21-1dfd8cab0f9f","message":{"text":"penis ass penis","fragments":[{"type":"text","text":"penis ass penis","cheermote":null,"emote":null}]}}} + +// message denied: +// {"subscription":{"id":"3a9fd3ec-f5b5-49d7-8624-2195b3ebfda9","status":"enabled","type":"channel.chat.user_message_update","version":"1","condition":{"broadcaster_user_id":"117166826","user_id":"159849156"},"transport":{"method":"websocket","session_id":"AgoQBFnyoXrLQxiCmkHTvJ8VNBIGY2VsbC1j"},"created_at":"2025-03-01T11:29:49.532375428Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","user_id":"159849156","user_login":"bajlada","user_name":"BajLada","status":"denied","message_id":"fc90dc3b-0634-41c3-8365-f40719f076ab","message":{"text":"penis ass penis","fragments":[{"type":"text","text":"penis ","cheermote":null,"emote":null},{"type":"text","text":"ass penis","cheermote":null,"emote":null}]}}} + +enum class Status : std::uint8_t { + Approved, + Denied, + Invalid, +}; + +struct Event { + // Broadcaster of the channel the message was sent in + String broadcasterUserID; + String broadcasterUserLogin; + String broadcasterUserName; + + // User who sent the message + String userID; + String userLogin; + String userName; + + Status status; + + String messageID; + chat::Message message; +}; + +struct Payload { + subscription::Subscription subscription; + + Event event; +}; + +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.inc" + +} // namespace chatterino::eventsub::lib::payload::channel_chat_user_message_update::v1 diff --git a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.inc b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.inc new file mode 100644 index 00000000..d9ba850f --- /dev/null +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.inc @@ -0,0 +1,8 @@ +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag, const boost::json::value &jvRoot); + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag, const boost::json::value &jvRoot); + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag, const boost::json::value &jvRoot); diff --git a/lib/twitch-eventsub-ws/src/CMakeLists.txt b/lib/twitch-eventsub-ws/src/CMakeLists.txt index f44a134d..04295009 100644 --- a/lib/twitch-eventsub-ws/src/CMakeLists.txt +++ b/lib/twitch-eventsub-ws/src/CMakeLists.txt @@ -12,6 +12,8 @@ generate_json_impls( include/twitch-eventsub-ws/payloads/channel-ban-v1.hpp include/twitch-eventsub-ws/payloads/channel-chat-message-v1.hpp include/twitch-eventsub-ws/payloads/channel-chat-notification-v1.hpp + include/twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp + include/twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp include/twitch-eventsub-ws/payloads/channel-moderate-v2.hpp include/twitch-eventsub-ws/payloads/channel-suspicious-user-message-v1.hpp include/twitch-eventsub-ws/payloads/channel-suspicious-user-update-v1.hpp diff --git a/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-hold-v1.cpp b/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-hold-v1.cpp new file mode 100644 index 00000000..57e184a7 --- /dev/null +++ b/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-hold-v1.cpp @@ -0,0 +1,186 @@ +// WARNING: This file is automatically generated. Any changes will be lost. +#include "twitch-eventsub-ws/chrono.hpp" // IWYU pragma: keep +#include "twitch-eventsub-ws/detail/errors.hpp" +#include "twitch-eventsub-ws/detail/variant.hpp" // IWYU pragma: keep +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp" + +#include + +namespace chatterino::eventsub::lib::payload::channel_chat_user_message_hold:: + v1 { + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag /* tag */, + const boost::json::value &jvRoot) +{ + if (!jvRoot.is_object()) + { + EVENTSUB_BAIL_HERE(error::Kind::ExpectedObject); + } + const auto &root = jvRoot.get_object(); + + const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id"); + if (jvbroadcasterUserID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserID = + boost::json::try_value_to(*jvbroadcasterUserID); + + if (broadcasterUserID.has_error()) + { + return broadcasterUserID.error(); + } + + const auto *jvbroadcasterUserLogin = + root.if_contains("broadcaster_user_login"); + if (jvbroadcasterUserLogin == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserLogin = + boost::json::try_value_to(*jvbroadcasterUserLogin); + + if (broadcasterUserLogin.has_error()) + { + return broadcasterUserLogin.error(); + } + + const auto *jvbroadcasterUserName = + root.if_contains("broadcaster_user_name"); + if (jvbroadcasterUserName == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserName = + boost::json::try_value_to(*jvbroadcasterUserName); + + if (broadcasterUserName.has_error()) + { + return broadcasterUserName.error(); + } + + const auto *jvuserID = root.if_contains("user_id"); + if (jvuserID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userID = boost::json::try_value_to(*jvuserID); + + if (userID.has_error()) + { + return userID.error(); + } + + const auto *jvuserLogin = root.if_contains("user_login"); + if (jvuserLogin == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userLogin = boost::json::try_value_to(*jvuserLogin); + + if (userLogin.has_error()) + { + return userLogin.error(); + } + + const auto *jvuserName = root.if_contains("user_name"); + if (jvuserName == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userName = boost::json::try_value_to(*jvuserName); + + if (userName.has_error()) + { + return userName.error(); + } + + const auto *jvmessageID = root.if_contains("message_id"); + if (jvmessageID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto messageID = boost::json::try_value_to(*jvmessageID); + + if (messageID.has_error()) + { + return messageID.error(); + } + + const auto *jvmessage = root.if_contains("message"); + if (jvmessage == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto message = boost::json::try_value_to(*jvmessage); + + if (message.has_error()) + { + return message.error(); + } + + return Event{ + .broadcasterUserID = std::move(broadcasterUserID.value()), + .broadcasterUserLogin = std::move(broadcasterUserLogin.value()), + .broadcasterUserName = std::move(broadcasterUserName.value()), + .userID = std::move(userID.value()), + .userLogin = std::move(userLogin.value()), + .userName = std::move(userName.value()), + .messageID = std::move(messageID.value()), + .message = std::move(message.value()), + }; +} + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag /* tag */, + const boost::json::value &jvRoot) +{ + if (!jvRoot.is_object()) + { + EVENTSUB_BAIL_HERE(error::Kind::ExpectedObject); + } + const auto &root = jvRoot.get_object(); + + const auto *jvsubscription = root.if_contains("subscription"); + if (jvsubscription == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto subscription = + boost::json::try_value_to(*jvsubscription); + + if (subscription.has_error()) + { + return subscription.error(); + } + + const auto *jvevent = root.if_contains("event"); + if (jvevent == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto event = boost::json::try_value_to(*jvevent); + + if (event.has_error()) + { + return event.error(); + } + + return Payload{ + .subscription = std::move(subscription.value()), + .event = std::move(event.value()), + }; +} + +} // namespace chatterino::eventsub::lib::payload::channel_chat_user_message_hold::v1 diff --git a/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-update-v1.cpp b/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-update-v1.cpp new file mode 100644 index 00000000..68df51a5 --- /dev/null +++ b/lib/twitch-eventsub-ws/src/generated/payloads/channel-chat-user-message-update-v1.cpp @@ -0,0 +1,229 @@ +// WARNING: This file is automatically generated. Any changes will be lost. +#include "twitch-eventsub-ws/chrono.hpp" // IWYU pragma: keep +#include "twitch-eventsub-ws/detail/errors.hpp" +#include "twitch-eventsub-ws/detail/variant.hpp" // IWYU pragma: keep +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp" + +#include + +namespace chatterino::eventsub::lib::payload::channel_chat_user_message_update:: + v1 { + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag /* tag */, + const boost::json::value &jvRoot) +{ + if (!jvRoot.is_string()) + { + EVENTSUB_BAIL_HERE(error::Kind::ExpectedString); + } + std::string_view eString(jvRoot.get_string()); + + using namespace std::string_view_literals; + if (eString == "approved"sv) + { + return Status::Approved; + } + if (eString == "denied"sv) + { + return Status::Denied; + } + if (eString == "invalid"sv) + { + return Status::Invalid; + } + EVENTSUB_BAIL_HERE(error::Kind::UnknownEnumValue); +} + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag /* tag */, + const boost::json::value &jvRoot) +{ + if (!jvRoot.is_object()) + { + EVENTSUB_BAIL_HERE(error::Kind::ExpectedObject); + } + const auto &root = jvRoot.get_object(); + + const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id"); + if (jvbroadcasterUserID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserID = + boost::json::try_value_to(*jvbroadcasterUserID); + + if (broadcasterUserID.has_error()) + { + return broadcasterUserID.error(); + } + + const auto *jvbroadcasterUserLogin = + root.if_contains("broadcaster_user_login"); + if (jvbroadcasterUserLogin == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserLogin = + boost::json::try_value_to(*jvbroadcasterUserLogin); + + if (broadcasterUserLogin.has_error()) + { + return broadcasterUserLogin.error(); + } + + const auto *jvbroadcasterUserName = + root.if_contains("broadcaster_user_name"); + if (jvbroadcasterUserName == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto broadcasterUserName = + boost::json::try_value_to(*jvbroadcasterUserName); + + if (broadcasterUserName.has_error()) + { + return broadcasterUserName.error(); + } + + const auto *jvuserID = root.if_contains("user_id"); + if (jvuserID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userID = boost::json::try_value_to(*jvuserID); + + if (userID.has_error()) + { + return userID.error(); + } + + const auto *jvuserLogin = root.if_contains("user_login"); + if (jvuserLogin == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userLogin = boost::json::try_value_to(*jvuserLogin); + + if (userLogin.has_error()) + { + return userLogin.error(); + } + + const auto *jvuserName = root.if_contains("user_name"); + if (jvuserName == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto userName = boost::json::try_value_to(*jvuserName); + + if (userName.has_error()) + { + return userName.error(); + } + + static_assert( + std::is_trivially_copyable_v< + std::remove_reference_t().status)>>); + const auto *jvstatus = root.if_contains("status"); + if (jvstatus == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto status = boost::json::try_value_to(*jvstatus); + + if (status.has_error()) + { + return status.error(); + } + + const auto *jvmessageID = root.if_contains("message_id"); + if (jvmessageID == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto messageID = boost::json::try_value_to(*jvmessageID); + + if (messageID.has_error()) + { + return messageID.error(); + } + + const auto *jvmessage = root.if_contains("message"); + if (jvmessage == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto message = boost::json::try_value_to(*jvmessage); + + if (message.has_error()) + { + return message.error(); + } + + return Event{ + .broadcasterUserID = std::move(broadcasterUserID.value()), + .broadcasterUserLogin = std::move(broadcasterUserLogin.value()), + .broadcasterUserName = std::move(broadcasterUserName.value()), + .userID = std::move(userID.value()), + .userLogin = std::move(userLogin.value()), + .userName = std::move(userName.value()), + .status = status.value(), + .messageID = std::move(messageID.value()), + .message = std::move(message.value()), + }; +} + +boost::json::result_for::type tag_invoke( + boost::json::try_value_to_tag /* tag */, + const boost::json::value &jvRoot) +{ + if (!jvRoot.is_object()) + { + EVENTSUB_BAIL_HERE(error::Kind::ExpectedObject); + } + const auto &root = jvRoot.get_object(); + + const auto *jvsubscription = root.if_contains("subscription"); + if (jvsubscription == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto subscription = + boost::json::try_value_to(*jvsubscription); + + if (subscription.has_error()) + { + return subscription.error(); + } + + const auto *jvevent = root.if_contains("event"); + if (jvevent == nullptr) + { + EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); + } + + auto event = boost::json::try_value_to(*jvevent); + + if (event.has_error()) + { + return event.error(); + } + + return Payload{ + .subscription = std::move(subscription.value()), + .event = std::move(event.value()), + }; +} + +} // namespace chatterino::eventsub::lib::payload::channel_chat_user_message_update::v1 diff --git a/lib/twitch-eventsub-ws/src/session.cpp b/lib/twitch-eventsub-ws/src/session.cpp index ebe01f9c..9ab7c729 100644 --- a/lib/twitch-eventsub-ws/src/session.cpp +++ b/lib/twitch-eventsub-ws/src/session.cpp @@ -212,6 +212,32 @@ namespace { return boost::system::error_code{}; }, }, + { + {"channel.chat.user_message_hold", "1"}, + [](const auto &metadata, const auto &jv, auto &listener) { + auto oPayload = parsePayload< + payload::channel_chat_user_message_hold::v1::Payload>(jv); + if (!oPayload) + { + return oPayload.error(); + } + listener->onChannelChatUserMessageHold(metadata, *oPayload); + return boost::system::error_code{}; + }, + }, + { + {"channel.chat.user_message_update", "1"}, + [](const auto &metadata, const auto &jv, auto &listener) { + auto oPayload = parsePayload< + payload::channel_chat_user_message_update::v1::Payload>(jv); + if (!oPayload) + { + return oPayload.error(); + } + listener->onChannelChatUserMessageUpdate(metadata, *oPayload); + return boost::system::error_code{}; + }, + }, // Add your new subscription types above this line }; diff --git a/lib/twitch-eventsub-ws/tests/src/parse.cpp b/lib/twitch-eventsub-ws/tests/src/parse.cpp index 258e30d5..c792cec2 100644 --- a/lib/twitch-eventsub-ws/tests/src/parse.cpp +++ b/lib/twitch-eventsub-ws/tests/src/parse.cpp @@ -129,6 +129,18 @@ class NoOpListener : public chatterino::eventsub::lib::Listener override { } + void onChannelChatUserMessageHold( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_hold::v1::Payload &payload) + override + { + } + void onChannelChatUserMessageUpdate( + const messages::Metadata &metadata, + const payload::channel_chat_user_message_update::v1::Payload &payload) + override + { + } }; } // namespace diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index d9628b75..58e36d68 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1554,6 +1554,9 @@ void TwitchChannel::refreshPubSub() }, }, }); + + this->eventSubChannelChatUserMessageHoldHandle.reset(); + this->eventSubChannelChatUserMessageUpdateHandle.reset(); } else { @@ -1561,7 +1564,42 @@ void TwitchChannel::refreshPubSub() this->eventSubAutomodMessageHoldHandle.reset(); this->eventSubAutomodMessageUpdateHandle.reset(); this->eventSubSuspiciousUserMessageHandle.reset(); + + this->eventSubChannelChatUserMessageHoldHandle = + getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{ + .subscriptionType = "channel.chat.user_message_hold", + .subscriptionVersion = "1", + .conditions = + { + { + "broadcaster_user_id", + roomId, + }, + { + "user_id", + currentAccount->getUserId(), + }, + }, + }); + + this->eventSubChannelChatUserMessageUpdateHandle = + getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{ + .subscriptionType = "channel.chat.user_message_update", + .subscriptionVersion = "1", + .conditions = + { + { + "broadcaster_user_id", + roomId, + }, + { + "user_id", + currentAccount->getUserId(), + }, + }, + }); } + getApp()->getTwitchPubSub()->listenToChannelPointRewards(roomId); } diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 4603a0b2..334bcfca 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -512,6 +512,8 @@ private: eventsub::SubscriptionHandle eventSubAutomodMessageHoldHandle; eventsub::SubscriptionHandle eventSubAutomodMessageUpdateHandle; eventsub::SubscriptionHandle eventSubSuspiciousUserMessageHandle; + eventsub::SubscriptionHandle eventSubChannelChatUserMessageHoldHandle; + eventsub::SubscriptionHandle eventSubChannelChatUserMessageUpdateHandle; friend class TwitchIrcServer; friend class MessageBuilder; diff --git a/src/providers/twitch/eventsub/Connection.cpp b/src/providers/twitch/eventsub/Connection.cpp index 04868a26..9bef7f0c 100644 --- a/src/providers/twitch/eventsub/Connection.cpp +++ b/src/providers/twitch/eventsub/Connection.cpp @@ -305,6 +305,57 @@ void Connection::onChannelSuspiciousUserUpdate( << payload.event.broadcasterUserLogin.qt(); } +void Connection::onChannelChatUserMessageHold( + const lib::messages::Metadata &metadata, + const lib::payload::channel_chat_user_message_hold::v1::Payload &payload) +{ + auto *channel = dynamic_cast( + getApp() + ->getTwitch() + ->getChannelOrEmpty(payload.event.broadcasterUserLogin.qt()) + .get()); + if (!channel || channel->isEmpty()) + { + qCDebug(LOG) << "Channel Chat User Message Hold for broadcaster we're " + "not interested in" + << payload.event.broadcasterUserLogin.qt(); + return; + } + + auto time = chronoToQDateTime(metadata.messageTimestamp); + auto message = makeUserMessageHeldMessage(channel, time, payload.event); + + runInGuiThread([channel, message] { + channel->addMessage(message, MessageContext::Original); + }); +} + +void Connection::onChannelChatUserMessageUpdate( + const lib::messages::Metadata &metadata, + const lib::payload::channel_chat_user_message_update::v1::Payload &payload) +{ + auto *channel = dynamic_cast( + getApp() + ->getTwitch() + ->getChannelOrEmpty(payload.event.broadcasterUserLogin.qt()) + .get()); + if (!channel || channel->isEmpty()) + { + qCDebug(LOG) + << "Channel Chat User Message Update for broadcaster we're " + "not interested in" + << payload.event.broadcasterUserLogin.qt(); + return; + } + + auto time = chronoToQDateTime(metadata.messageTimestamp); + auto message = makeUserMessageUpdateMessage(channel, time, payload.event); + + runInGuiThread([channel, message] { + channel->addMessage(message, MessageContext::Original); + }); +} + QString Connection::getSessionID() const { return this->sessionID; diff --git a/src/providers/twitch/eventsub/Connection.hpp b/src/providers/twitch/eventsub/Connection.hpp index 9a04b729..6aad8dd2 100644 --- a/src/providers/twitch/eventsub/Connection.hpp +++ b/src/providers/twitch/eventsub/Connection.hpp @@ -71,6 +71,16 @@ public: const lib::payload::channel_suspicious_user_update::v1::Payload &payload) override; + void onChannelChatUserMessageHold( + const lib::messages::Metadata &metadata, + const lib::payload::channel_chat_user_message_hold::v1::Payload + &payload) override; + + void onChannelChatUserMessageUpdate( + const lib::messages::Metadata &metadata, + const lib::payload::channel_chat_user_message_update::v1::Payload + &payload) override; + QString getSessionID() const; bool isSubscribedTo(const SubscriptionRequest &request) const; diff --git a/src/providers/twitch/eventsub/MessageBuilder.cpp b/src/providers/twitch/eventsub/MessageBuilder.cpp index a91be0d2..76901a37 100644 --- a/src/providers/twitch/eventsub/MessageBuilder.cpp +++ b/src/providers/twitch/eventsub/MessageBuilder.cpp @@ -756,4 +756,86 @@ MessagePtr makeSuspiciousUserMessageBody( return builder.release(); } +MessagePtr makeUserMessageHeldMessage( + TwitchChannel *channel, const QDateTime &time, + const lib::payload::channel_chat_user_message_hold::v1::Event &event) +{ + QString text("AutoMod: Hey! Your message is being checked by mods and has " + "not been sent."); + EventSubMessageBuilder builder(channel); + builder->serverReceivedTime = time; + builder->id = u"automod_" % event.messageID.qt(); + builder->loginName = u"automod"_s; + builder->channelName = event.broadcasterUserLogin.qt(); + builder->flags.set(MessageFlag::PubSub, MessageFlag::Timeout, + MessageFlag::AutoMod); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::Text, + QColor(0, 0, 255), FontStyle::ChatMediumBold); + builder.emplace( + "Hey! Your message is being checked by mods and has not been sent.", + MessageElementFlag::Text, MessageColor::Text); + + builder->messageText = text; + builder->searchText = text; + + return builder.release(); +} + +MessagePtr makeUserMessageUpdateMessage( + TwitchChannel *channel, const QDateTime &time, + const lib::payload::channel_chat_user_message_update::v1::Event &event) +{ + using lib::payload::channel_chat_user_message_update::v1::Status; + + QString text("AutoMod: "); + EventSubMessageBuilder builder(channel); + builder->serverReceivedTime = time; + builder->id = u"automod_" % event.messageID.qt(); + builder->loginName = u"automod"_s; + builder->channelName = event.broadcasterUserLogin.qt(); + builder->flags.set(MessageFlag::PubSub, MessageFlag::Timeout, + MessageFlag::AutoMod); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::Text, + QColor(0, 0, 255), FontStyle::ChatMediumBold); + + switch (event.status) + { + case Status::Approved: + text += "Mods have accepted your message."; + builder.emplace("Mods have accepted your message.", + MessageElementFlag::Text, + MessageColor::Text); + break; + + case Status::Denied: + text += "Mods have denied your message."; + builder.emplace("Mods have denied your message.", + MessageElementFlag::Text, + MessageColor::Text); + break; + + case Status::Invalid: + text += "Your message was lost in the void."; + builder.emplace("Your message was lost in the void.", + MessageElementFlag::Text, + MessageColor::Text); + break; + } + + builder->messageText = text; + builder->searchText = text; + + return builder.release(); +} + } // namespace chatterino::eventsub diff --git a/src/providers/twitch/eventsub/MessageBuilder.hpp b/src/providers/twitch/eventsub/MessageBuilder.hpp index 0e988b92..7c75da08 100644 --- a/src/providers/twitch/eventsub/MessageBuilder.hpp +++ b/src/providers/twitch/eventsub/MessageBuilder.hpp @@ -3,6 +3,8 @@ #include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "twitch-eventsub-ws/payloads/automod-message-hold-v2.hpp" +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-hold-v1.hpp" +#include "twitch-eventsub-ws/payloads/channel-chat-user-message-update-v1.hpp" #include "twitch-eventsub-ws/payloads/channel-moderate-v2.hpp" #include "twitch-eventsub-ws/payloads/channel-suspicious-user-message-v1.hpp" @@ -165,4 +167,12 @@ MessagePtr makeSuspiciousUserMessageBody( TwitchChannel *channel, const QDateTime &time, const lib::payload::channel_suspicious_user_message::v1::Event &event); +MessagePtr makeUserMessageHeldMessage( + TwitchChannel *channel, const QDateTime &time, + const lib::payload::channel_chat_user_message_hold::v1::Event &event); + +MessagePtr makeUserMessageUpdateMessage( + TwitchChannel *channel, const QDateTime &time, + const lib::payload::channel_chat_user_message_update::v1::Event &event); + } // namespace chatterino::eventsub diff --git a/tests/snapshots/EventSub/channel-chat-user-message-hold/message-held.json b/tests/snapshots/EventSub/channel-chat-user-message-hold/message-held.json new file mode 100644 index 00000000..0cc6c809 --- /dev/null +++ b/tests/snapshots/EventSub/channel-chat-user-message-hold/message-held.json @@ -0,0 +1,105 @@ +{ + "input": { + "broadcaster_user_id": "11148817", + "broadcaster_user_login": "pajlada", + "broadcaster_user_name": "pajlada", + "message": { + "fragments": [ + { + "cheermote": null, + "emote": null, + "text": "penis penis penis", + "type": "text" + } + ], + "text": "penis penis penis" + }, + "message_id": "e39e3c58-3d25-49fd-8c94-e776ef57a7f8", + "user_id": "159849156", + "user_login": "bajlada", + "user_name": "BajLada" + }, + "output": [ + { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "pajlada", + "count": 1, + "displayName": "", + "elements": [ + { + "emote": { + "homePage": "https://dashboard.twitch.tv/settings/moderation/automod", + "images": { + "1x": "" + }, + "name": "", + "tooltip": "AutoMod" + }, + "flags": "BadgeChannelAuthority", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "AutoMod", + "trailingSpace": true, + "type": "BadgeElement" + }, + { + "color": "#ff0000ff", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMediumBold", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "AutoMod:" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "Hey!", + "Your", + "message", + "is", + "being", + "checked", + "by", + "mods", + "and", + "has", + "not", + "been", + "sent." + ] + } + ], + "flags": "Timeout|PubSub|AutoMod|EventSub", + "id": "automod_e39e3c58-3d25-49fd-8c94-e776ef57a7f8", + "localizedName": "", + "loginName": "automod", + "messageText": "AutoMod: Hey! Your message is being checked by mods and has not been sent.", + "searchText": "AutoMod: Hey! Your message is being checked by mods and has not been sent.", + "serverReceivedTime": "2024-05-14T12:31:47Z", + "timeoutUser": "", + "userID": "", + "usernameColor": "#ff000000" + } + ] +} diff --git a/tests/snapshots/EventSub/channel-chat-user-message-update/message-approved.json b/tests/snapshots/EventSub/channel-chat-user-message-update/message-approved.json new file mode 100644 index 00000000..7fa13818 --- /dev/null +++ b/tests/snapshots/EventSub/channel-chat-user-message-update/message-approved.json @@ -0,0 +1,98 @@ +{ + "input": { + "broadcaster_user_id": "11148817", + "broadcaster_user_login": "pajlada", + "broadcaster_user_name": "pajlada", + "message": { + "fragments": [ + { + "cheermote": null, + "emote": null, + "text": "penis ass penis", + "type": "text" + } + ], + "text": "penis ass penis" + }, + "message_id": "5686ec52-e02c-4042-ac21-1dfd8cab0f9f", + "status": "approved", + "user_id": "159849156", + "user_login": "bajlada", + "user_name": "BajLada" + }, + "output": [ + { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "pajlada", + "count": 1, + "displayName": "", + "elements": [ + { + "emote": { + "homePage": "https://dashboard.twitch.tv/settings/moderation/automod", + "images": { + "1x": "" + }, + "name": "", + "tooltip": "AutoMod" + }, + "flags": "BadgeChannelAuthority", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "AutoMod", + "trailingSpace": true, + "type": "BadgeElement" + }, + { + "color": "#ff0000ff", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMediumBold", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "AutoMod:" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "Mods", + "have", + "accepted", + "your", + "message." + ] + } + ], + "flags": "Timeout|PubSub|AutoMod|EventSub", + "id": "automod_5686ec52-e02c-4042-ac21-1dfd8cab0f9f", + "localizedName": "", + "loginName": "automod", + "messageText": "AutoMod: Mods have accepted your message.", + "searchText": "AutoMod: Mods have accepted your message.", + "serverReceivedTime": "2024-05-14T12:31:47Z", + "timeoutUser": "", + "userID": "", + "usernameColor": "#ff000000" + } + ] +} diff --git a/tests/snapshots/EventSub/channel-chat-user-message-update/message-denied.json b/tests/snapshots/EventSub/channel-chat-user-message-update/message-denied.json new file mode 100644 index 00000000..26ad8ea0 --- /dev/null +++ b/tests/snapshots/EventSub/channel-chat-user-message-update/message-denied.json @@ -0,0 +1,104 @@ +{ + "input": { + "broadcaster_user_id": "11148817", + "broadcaster_user_login": "pajlada", + "broadcaster_user_name": "pajlada", + "message": { + "fragments": [ + { + "cheermote": null, + "emote": null, + "text": "penis ", + "type": "text" + }, + { + "cheermote": null, + "emote": null, + "text": "ass penis", + "type": "text" + } + ], + "text": "penis ass penis" + }, + "message_id": "fc90dc3b-0634-41c3-8365-f40719f076ab", + "status": "denied", + "user_id": "159849156", + "user_login": "bajlada", + "user_name": "BajLada" + }, + "output": [ + { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "pajlada", + "count": 1, + "displayName": "", + "elements": [ + { + "emote": { + "homePage": "https://dashboard.twitch.tv/settings/moderation/automod", + "images": { + "1x": "" + }, + "name": "", + "tooltip": "AutoMod" + }, + "flags": "BadgeChannelAuthority", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "AutoMod", + "trailingSpace": true, + "type": "BadgeElement" + }, + { + "color": "#ff0000ff", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMediumBold", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "AutoMod:" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "Mods", + "have", + "denied", + "your", + "message." + ] + } + ], + "flags": "Timeout|PubSub|AutoMod|EventSub", + "id": "automod_fc90dc3b-0634-41c3-8365-f40719f076ab", + "localizedName": "", + "loginName": "automod", + "messageText": "AutoMod: Mods have denied your message.", + "searchText": "AutoMod: Mods have denied your message.", + "serverReceivedTime": "2024-05-14T12:31:47Z", + "timeoutUser": "", + "userID": "", + "usernameColor": "#ff000000" + } + ] +} diff --git a/tests/src/EventSubMessages.cpp b/tests/src/EventSubMessages.cpp index fb483cf5..c6084a15 100644 --- a/tests/src/EventSubMessages.cpp +++ b/tests/src/EventSubMessages.cpp @@ -120,6 +120,44 @@ const std::map "cost": 0 })", }, + { + "channel-chat-user-message-hold", + R"({ + "id": "a3122e32-6498-4847-8675-109b9b94f29c", + "status": "enabled", + "type": "channel.chat.user_message_hold", + "version": "1", + "condition": { + "broadcaster_user_id": "11148817", + "moderator_user_id": "489584266" + }, + "transport": { + "method":"websocket", + "session_id":"AgoQ59RRLw0mS6S000QtK8f54BIGY2VsbC1j" + }, + "created_at": "2025-02-28T15:55:37.85489173Z", + "cost": 0 + })", + }, + { + "channel-chat-user-message-update", + R"({ + "id": "a3122e32-6498-4847-8675-109b9b94f29c", + "status": "enabled", + "type": "channel.chat.user_message_update", + "version": "1", + "condition": { + "broadcaster_user_id": "11148817", + "moderator_user_id": "489584266" + }, + "transport": { + "method":"websocket", + "session_id":"AgoQ59RRLw0mS6S000QtK8f54BIGY2VsbC1j" + }, + "created_at": "2025-02-28T15:55:37.85489173Z", + "cost": 0 + })", + }, }; class MockApplication : public mock::BaseApplication