diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bde2cde..a3bde57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Bugfix: Fixed the input font not immediately updating when zooming in/out. (#5960) - Bugfix: Fixed color input thinking blue is also red. (#5982) - 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) +- 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) - 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/include/twitch-eventsub-ws/payloads/channel-moderate-v2.hpp b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-moderate-v2.hpp index 9734f8cc..a7fb73c3 100644 --- a/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-moderate-v2.hpp +++ b/lib/twitch-eventsub-ws/include/twitch-eventsub-ws/payloads/channel-moderate-v2.hpp @@ -5,6 +5,7 @@ #include +#include #include namespace chatterino::eventsub::lib::payload::channel_moderate::v2 { @@ -197,15 +198,14 @@ struct SharedChatUnban : public Unban { struct Timeout { static constexpr std::string_view TAG = "timeout"; - std::string userID; - std::string userLogin; - std::string userName; - // TODO: Verify that we handle null here - std::string reason; - // TODO: This should be a timestamp? - std::string expiresAt; + String userID; + String userLogin; + String userName; + String reason; + /// json_tag=AsISO8601 + std::chrono::system_clock::time_point expiresAt; }; -struct SharedChatTimeout : public Ban { +struct SharedChatTimeout : public Timeout { static constexpr std::string_view TAG = "shared_chat_timeout"; }; @@ -220,7 +220,7 @@ struct Untimeout { std::string userLogin; std::string userName; }; -struct SharedChatUntimeout : public Ban { +struct SharedChatUntimeout : public Untimeout { static constexpr std::string_view TAG = "shared_chat_untimeout"; }; diff --git a/lib/twitch-eventsub-ws/src/generated/payloads/channel-moderate-v2.cpp b/lib/twitch-eventsub-ws/src/generated/payloads/channel-moderate-v2.cpp index 5f4c9cf3..d798cdfa 100644 --- a/lib/twitch-eventsub-ws/src/generated/payloads/channel-moderate-v2.cpp +++ b/lib/twitch-eventsub-ws/src/generated/payloads/channel-moderate-v2.cpp @@ -477,7 +477,7 @@ boost::json::result_for::type tag_invoke( EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); } - auto userID = boost::json::try_value_to(*jvuserID); + auto userID = boost::json::try_value_to(*jvuserID); if (userID.has_error()) { @@ -490,7 +490,7 @@ boost::json::result_for::type tag_invoke( EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); } - auto userLogin = boost::json::try_value_to(*jvuserLogin); + auto userLogin = boost::json::try_value_to(*jvuserLogin); if (userLogin.has_error()) { @@ -503,7 +503,7 @@ boost::json::result_for::type tag_invoke( EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); } - auto userName = boost::json::try_value_to(*jvuserName); + auto userName = boost::json::try_value_to(*jvuserName); if (userName.has_error()) { @@ -516,20 +516,24 @@ boost::json::result_for::type tag_invoke( EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); } - auto reason = boost::json::try_value_to(*jvreason); + auto reason = boost::json::try_value_to(*jvreason); if (reason.has_error()) { return reason.error(); } + static_assert(std::is_trivially_copyable_v().expiresAt)>>); const auto *jvexpiresAt = root.if_contains("expires_at"); if (jvexpiresAt == nullptr) { EVENTSUB_BAIL_HERE(error::Kind::FieldMissing); } - auto expiresAt = boost::json::try_value_to(*jvexpiresAt); + auto expiresAt = + boost::json::try_value_to( + *jvexpiresAt, AsISO8601()); if (expiresAt.has_error()) { @@ -541,7 +545,7 @@ boost::json::result_for::type tag_invoke( .userLogin = std::move(userLogin.value()), .userName = std::move(userName.value()), .reason = std::move(reason.value()), - .expiresAt = std::move(expiresAt.value()), + .expiresAt = expiresAt.value(), }; } @@ -549,7 +553,7 @@ boost::json::result_for::type tag_invoke( boost::json::try_value_to_tag /* tag */, const boost::json::value &jvRoot) { - auto base = boost::json::try_value_to(jvRoot); + auto base = boost::json::try_value_to(jvRoot); if (base.has_error()) { return base.error(); @@ -618,7 +622,7 @@ boost::json::result_for::type tag_invoke(boost::json::try_value_to_tag /* tag */, const boost::json::value &jvRoot) { - auto base = boost::json::try_value_to(jvRoot); + auto base = boost::json::try_value_to(jvRoot); if (base.has_error()) { return base.error(); diff --git a/src/providers/twitch/eventsub/MessageHandlers.cpp b/src/providers/twitch/eventsub/MessageHandlers.cpp index ef8caa14..c4ac5f08 100644 --- a/src/providers/twitch/eventsub/MessageHandlers.cpp +++ b/src/providers/twitch/eventsub/MessageHandlers.cpp @@ -3,9 +3,11 @@ #include "Application.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/twitch/eventsub/MessageBuilder.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/PostToThread.hpp" namespace chatterino::eventsub { @@ -27,18 +29,59 @@ void handleModerateMessage( }); } -void addModerateMessage( - TwitchChannel *channel, MessagePtr message, const QDateTime &time, - const lib::payload::channel_moderate::v2::Ban & /*action*/) +void handleModerateMessage( + TwitchChannel *chan, const QDateTime &time, + const lib::payload::channel_moderate::v2::Event &event, + const lib::payload::channel_moderate::v2::Timeout &action) { - runInGuiThread([channel, msg{std::move(message)}, time] { - channel->addOrReplaceTimeout(msg, time); - if (getSettings()->hideModerated) - { - // XXX: This is expensive. We could use a layout request if the layout - // would store the previous message flags. - getApp()->getWindows()->forceLayoutChannelViews(); - } + // Not all compilers support QDateTime::toStdSysMilliseconds + std::chrono::system_clock::time_point chronoTime{ + std::chrono::milliseconds{time.toMSecsSinceEpoch()}}; + + auto duration = + std::chrono::round(action.expiresAt - chronoTime); + + EventSubMessageBuilder builder(chan, time); + builder->loginName = event.moderatorUserLogin.qt(); + + QString text; + bool isShared = event.isFromSharedChat(); + + builder.appendUser(event.moderatorUserName, event.moderatorUserLogin, text); + builder.emplaceSystemTextAndUpdate("timed out", text); + builder.appendUser(action.userName, action.userLogin, text); + + builder.emplaceSystemTextAndUpdate("for", text); + builder + .emplaceSystemTextAndUpdate( + formatTime(static_cast(duration.count())), text) + ->setTrailingSpace(isShared); + + if (isShared) + { + builder.emplaceSystemTextAndUpdate("in", text); + builder.appendUser(*event.sourceBroadcasterUserName, + *event.sourceBroadcasterUserLogin, text, false); + } + + if (action.reason.view().empty()) + { + builder.emplaceSystemTextAndUpdate(".", text); + } + else + { + builder.emplaceSystemTextAndUpdate(":", text); + builder.emplaceSystemTextAndUpdate(action.reason.qt(), text); + } + + builder->messageText = text; + builder->searchText = text; + builder->timeoutUser = action.userLogin.qt(); + + auto msg = builder.release(); + runInGuiThread([chan, msg] { + // TODO: addOrReplaceTimeout (doesn't work with shared chat yet) + chan->addMessage(msg, MessageContext::Original); }); } diff --git a/src/providers/twitch/eventsub/MessageHandlers.hpp b/src/providers/twitch/eventsub/MessageHandlers.hpp index 6850f7e6..e4fc84df 100644 --- a/src/providers/twitch/eventsub/MessageHandlers.hpp +++ b/src/providers/twitch/eventsub/MessageHandlers.hpp @@ -22,4 +22,9 @@ void handleModerateMessage( const lib::payload::channel_moderate::v2::Event &event, const lib::payload::channel_moderate::v2::Clear &action); +void handleModerateMessage( + TwitchChannel *chan, const QDateTime &time, + const lib::payload::channel_moderate::v2::Event &event, + const lib::payload::channel_moderate::v2::Timeout &action); + } // namespace chatterino::eventsub diff --git a/src/util/ChannelHelpers.hpp b/src/util/ChannelHelpers.hpp index 7c5124c8..1fc1f8aa 100644 --- a/src/util/ChannelHelpers.hpp +++ b/src/util/ChannelHelpers.hpp @@ -52,6 +52,11 @@ void addOrReplaceChannelTimeout(const Buf &buffer, MessagePtr message, break; } + if (s->flags.has(MessageFlag::EventSub)) + { + continue; // TODO: implement stacking for eventsub + } + if (timeoutStackStyle == TimeoutStackStyle::DontStackBeyondUserMessage) { if (s->loginName == message->timeoutUser && diff --git a/tests/snapshots/EventSub/channel-moderate/shared-timeout.json b/tests/snapshots/EventSub/channel-moderate/shared-timeout.json new file mode 100644 index 00000000..4e63c279 --- /dev/null +++ b/tests/snapshots/EventSub/channel-moderate/shared-timeout.json @@ -0,0 +1,222 @@ +{ + "input": { + "__timestamp": "2025-02-22T17:54:04.523432776Z", + "action": "shared_chat_timeout", + "automod_terms": null, + "ban": null, + "broadcaster_user_id": "11148817", + "broadcaster_user_login": "pajlada", + "broadcaster_user_name": "pajlada", + "delete": null, + "followers": null, + "mod": null, + "moderator_user_id": "129546453", + "moderator_user_login": "nerixyz", + "moderator_user_name": "nerixyz", + "raid": null, + "shared_chat_ban": null, + "shared_chat_delete": null, + "shared_chat_timeout": { + "expires_at": "2025-02-22T18:04:04.502931622Z", + "reason": "", + "user_id": "141981764", + "user_login": "twitchdev", + "user_name": "TwitchDev" + }, + "shared_chat_unban": null, + "shared_chat_untimeout": null, + "slow": null, + "source_broadcaster_user_id": "117166826", + "source_broadcaster_user_login": "testaccount_420", + "source_broadcaster_user_name": "테스트계정420", + "timeout": null, + "unban": null, + "unban_request": null, + "unmod": null, + "unraid": null, + "untimeout": null, + "unvip": null, + "vip": null, + "warn": null + }, + "output": [ + { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "17:54" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "17:54:04", + "tooltip": "", + "trailingSpace": true, + "type": "TimestampElement" + }, + { + "color": "Text", + "fallbackColor": "System", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "System", + "userLoginName": "nerixyz", + "words": [ + "nerixyz" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "timed", + "out" + ] + }, + { + "color": "Text", + "fallbackColor": "System", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "System", + "userLoginName": "twitchdev", + "words": [ + "TwitchDev" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "for" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "10m" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "in" + ] + }, + { + "color": "Text", + "fallbackColor": "System", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": false, + "type": "MentionElement", + "userColor": "System", + "userLoginName": "testaccount_420", + "words": [ + "테스트계정420" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "." + ] + } + ], + "flags": "System|Timeout|EventSub", + "id": "", + "localizedName": "", + "loginName": "nerixyz", + "messageText": "nerixyz timed out twitchdev for 10m in testaccount_420. ", + "searchText": "nerixyz timed out twitchdev for 10m in testaccount_420. ", + "serverReceivedTime": "2025-02-22T17:54:04Z", + "timeoutUser": "twitchdev", + "userID": "", + "usernameColor": "#ff000000" + } + ] +} diff --git a/tests/snapshots/EventSub/channel-moderate/timeout.json b/tests/snapshots/EventSub/channel-moderate/timeout.json new file mode 100644 index 00000000..ca44797f --- /dev/null +++ b/tests/snapshots/EventSub/channel-moderate/timeout.json @@ -0,0 +1,204 @@ +{ + "input": { + "__timestamp": "2025-02-22T17:53:49.800959069Z", + "action": "timeout", + "automod_terms": null, + "ban": null, + "broadcaster_user_id": "11148817", + "broadcaster_user_login": "pajlada", + "broadcaster_user_name": "pajlada", + "delete": null, + "followers": null, + "mod": null, + "moderator_user_id": "129546453", + "moderator_user_login": "nerixyz", + "moderator_user_name": "nerixyz", + "raid": null, + "shared_chat_ban": null, + "shared_chat_delete": null, + "shared_chat_timeout": null, + "shared_chat_unban": null, + "shared_chat_untimeout": null, + "slow": null, + "source_broadcaster_user_id": null, + "source_broadcaster_user_login": null, + "source_broadcaster_user_name": null, + "timeout": { + "expires_at": "2025-02-22T17:53:50.782129948Z", + "reason": "test", + "user_id": "141981764", + "user_login": "twitchdev", + "user_name": "TwitchDev" + }, + "unban": null, + "unban_request": null, + "unmod": null, + "unraid": null, + "untimeout": null, + "unvip": null, + "vip": null, + "warn": null + }, + "output": [ + { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "17:53" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "17:53:49", + "tooltip": "", + "trailingSpace": true, + "type": "TimestampElement" + }, + { + "color": "Text", + "fallbackColor": "System", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "System", + "userLoginName": "nerixyz", + "words": [ + "nerixyz" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "timed", + "out" + ] + }, + { + "color": "Text", + "fallbackColor": "System", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "System", + "userLoginName": "twitchdev", + "words": [ + "TwitchDev" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "for" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": false, + "type": "TextElement", + "words": [ + "1s" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + ":" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "test" + ] + } + ], + "flags": "System|Timeout|EventSub", + "id": "", + "localizedName": "", + "loginName": "nerixyz", + "messageText": "nerixyz timed out twitchdev for 1s : test ", + "searchText": "nerixyz timed out twitchdev for 1s : test ", + "serverReceivedTime": "2025-02-22T17:53:49Z", + "timeoutUser": "twitchdev", + "userID": "", + "usernameColor": "#ff000000" + } + ] +} diff --git a/tests/src/EventSubMessages.cpp b/tests/src/EventSubMessages.cpp index 44d37a78..d31c425e 100644 --- a/tests/src/EventSubMessages.cpp +++ b/tests/src/EventSubMessages.cpp @@ -96,17 +96,25 @@ std::shared_ptr makeMockTwitchChannel(const QString &name) } boost::beast::flat_buffer makePayload(std::string_view subJson, - const QJsonObject &event) + QJsonObject event) { auto subscription = QJsonDocument::fromJson( QByteArray::fromRawData(subJson.data(), static_cast(subJson.size()))) .object(); + + QString timestamp = "2024-05-14T12:31:47.995298776Z"; + if (event.contains("__timestamp")) + { + timestamp = event["__timestamp"].toString(); + event.remove("__timestamp"); + } + QJsonObject metadata{ {"message_id", "e8edc592-5550-4aa5-bba6-39e31a2be435"}, {"message_type", "notification"}, - {"message_timestamp", "2024-05-14T12:31:47.995298776Z"}, + {"message_timestamp", timestamp}, {"subscription_type", subscription["type"]}, {"subscription_version", subscription["version"]}, };