From 693d4f401d1e35f9eb691a92cff7388dd0ba2d99 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sat, 6 Jan 2024 07:22:00 -0600 Subject: [PATCH] feat: add badges, emotes, and filters for suspicious messages (#5060) * feat: show chat badges on suspicious user messages * feat: display emotes in suspicious user messages * feat: add search filters for suspicious messages * chore: update changelog * refactor: resolve initial nits * fix: finish adding new filter identifier * Comment the new message flags * Add a list of known issues to low trust update messages * fix: Keep shared-pointerness of the channel Without this change, we would have the possibility of using the TwitchChannel after the Channel itself has gone out of scope, albeit not realistically since we just post this to a thread and parse it - there's no networking or big delays involved. but this shows the intent better --------- Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 +- src/Application.cpp | 16 +- src/controllers/filters/lang/Filter.cpp | 4 + src/controllers/filters/lang/Filter.hpp | 2 + src/controllers/filters/lang/Tokenizer.hpp | 2 + src/messages/Message.hpp | 4 + src/messages/search/MessageFlagsPredicate.cpp | 8 + src/providers/twitch/TwitchMessageBuilder.cpp | 248 ++++++++++-------- src/providers/twitch/TwitchMessageBuilder.hpp | 4 +- .../twitch/pubsubmessages/LowTrustUsers.cpp | 23 +- .../twitch/pubsubmessages/LowTrustUsers.hpp | 35 ++- 11 files changed, 215 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00312f7..31305fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Major: Allow use of Twitch follower emotes in other channels if subscribed. (#4922) - Major: Add `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026) -- Major: Show restricted chat messages and suspicious treatment updates. (#5056) +- Major: Show restricted chat messages and suspicious treatment updates. (#5056, #5060) - Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) diff --git a/src/Application.cpp b/src/Application.cpp index 81b604cf..e13624c7 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -525,12 +525,20 @@ void Application::initPubSub() return; } - postToThread([chan, action] { + auto twitchChannel = + std::dynamic_pointer_cast(chan); + if (!twitchChannel) + { + return; + } + + postToThread([twitchChannel, action] { const auto p = TwitchMessageBuilder::makeLowTrustUserMessage( - action, chan->getName()); - chan->addMessage(p.first); - chan->addMessage(p.second); + action, twitchChannel->getName(), + twitchChannel.get()); + twitchChannel->addMessage(p.first); + twitchChannel->addMessage(p.second); }); }); diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp index 9f31ec1e..7ae61991 100644 --- a/src/controllers/filters/lang/Filter.cpp +++ b/src/controllers/filters/lang/Filter.cpp @@ -44,6 +44,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.whisper * flags.reply * flags.automod + * flags.restricted + * flags.monitored * * message.content * message.length @@ -101,6 +103,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, + {"flags.restricted", m->flags.has(MessageFlag::RestrictedMessage)}, + {"flags.monitored", m->flags.has(MessageFlag::MonitoredMessage)}, {"message.content", m->messageText}, {"message.length", m->messageText.length()}, diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp index 6ebfdb94..c8afbd76 100644 --- a/src/controllers/filters/lang/Filter.hpp +++ b/src/controllers/filters/lang/Filter.hpp @@ -44,6 +44,8 @@ static const QMap MESSAGE_TYPING_CONTEXT = { {"flags.whisper", Type::Bool}, {"flags.reply", Type::Bool}, {"flags.automod", Type::Bool}, + {"flags.restricted", Type::Bool}, + {"flags.monitored", Type::Bool}, {"message.content", Type::String}, {"message.length", Type::Int}, }; diff --git a/src/controllers/filters/lang/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp index 567c8d13..2fbc5fd9 100644 --- a/src/controllers/filters/lang/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -32,6 +32,8 @@ static const QMap validIdentifiersMap = { {"flags.whisper", "whisper message?"}, {"flags.reply", "reply message?"}, {"flags.automod", "automod message?"}, + {"flags.restricted", "restricted message?"}, + {"flags.monitored", "monitored message?"}, {"message.content", "message text"}, {"message.length", "message length"}}; diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 30c1308a..82de23fe 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -53,6 +53,10 @@ enum class MessageFlag : int64_t { /// The message caught by AutoMod containing the user who sent the message & its contents AutoModOffendingMessage = (1LL << 31), LowTrustUsers = (1LL << 32), + /// The message is sent by a user marked as restricted with Twitch's "Low Trust"/"Suspicious User" feature + RestrictedMessage = (1LL << 33), + /// The message is sent by a user marked as monitor with Twitch's "Low Trust"/"Suspicious User" feature + MonitoredMessage = (1LL << 34), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index c1525769..76e32de7 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -52,6 +52,14 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate) { this->flags_.set(MessageFlag::ReplyMessage); } + else if (flag == "restricted") + { + this->flags_.set(MessageFlag::RestrictedMessage); + } + else if (flag == "monitored") + { + this->flags_.set(MessageFlag::MonitoredMessage); + } } } diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 0035f52c..47fcf93f 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -153,6 +153,119 @@ namespace { } } + std::optional getTwitchBadge(const Badge &badge, + const TwitchChannel *twitchChannel) + { + if (auto channelBadge = + twitchChannel->twitchBadge(badge.key_, badge.value_)) + { + return channelBadge; + } + + if (auto globalBadge = + TwitchBadges::instance()->badge(badge.key_, badge.value_)) + { + return globalBadge; + } + + return std::nullopt; + } + + void appendBadges(MessageBuilder *builder, const std::vector &badges, + const std::unordered_map &badgeInfos, + const TwitchChannel *twitchChannel) + { + if (twitchChannel == nullptr) + { + return; + } + + for (const auto &badge : badges) + { + auto badgeEmote = getTwitchBadge(badge, twitchChannel); + 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 = twitchChannel->ffzCustomModBadge()) + { + builder + ->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 = twitchChannel->ffzCustomVipBadge()) + { + builder + ->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 infoValue = badgeInfoIt->second; + auto predictionText = + infoValue + .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); + } + } + + builder->emplace(*badgeEmote, badge.flag_) + ->setTooltip(tooltip); + } + + builder->message().badges = badges; + builder->message().badgeInfos = badgeInfos; + } + } // namespace TwitchMessageBuilder::TwitchMessageBuilder( @@ -1113,24 +1226,6 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) return Failure; } -std::optional TwitchMessageBuilder::getTwitchBadge( - const Badge &badge) const -{ - if (auto channelBadge = - this->twitchChannel->twitchBadge(badge.key_, badge.value_)) - { - return channelBadge; - } - - if (auto globalBadge = - TwitchBadges::instance()->badge(badge.key_, badge.value_)) - { - return globalBadge; - } - - return std::nullopt; -} - std::unordered_map TwitchMessageBuilder::parseBadgeInfoTag( const QVariantMap &tags) { @@ -1189,88 +1284,8 @@ void TwitchMessageBuilder::appendTwitchBadges() } auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); - auto badges = this->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; + auto badges = TwitchMessageBuilder::parseBadgeTag(this->tags); + appendBadges(this, badges, badgeInfos, this->twitchChannel); } void TwitchMessageBuilder::appendChatterinoBadges() @@ -1936,6 +1951,12 @@ std::pair TwitchMessageBuilder::makeAutomodMessage( MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage( const PubSubLowTrustUsersMessage &action) { + /** + * Known issues: + * - Non-Twitch badges are not shown + * - Non-Twitch emotes are not shown + */ + MessageBuilder builder; builder.emplace(); builder.message().flags.set(MessageFlag::System); @@ -2006,7 +2027,8 @@ MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage( } std::pair TwitchMessageBuilder::makeLowTrustUserMessage( - const PubSubLowTrustUsersMessage &action, const QString &channelName) + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel) { MessageBuilder builder, builder2; @@ -2029,10 +2051,12 @@ std::pair TwitchMessageBuilder::makeLowTrustUserMessage( if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted) { headerMessage = "Restricted"; + builder2.message().flags.set(MessageFlag::RestrictedMessage); } else { headerMessage = "Monitored"; + builder2.message().flags.set(MessageFlag::MonitoredMessage); } if (action.restrictionTypes.has( @@ -2089,6 +2113,9 @@ std::pair TwitchMessageBuilder::makeLowTrustUserMessage( builder2.message().flags.set(MessageFlag::PubSub); builder2.message().flags.set(MessageFlag::LowTrustUsers); + // sender badges + appendBadges(&builder2, action.senderBadges, {}, twitchChannel); + // sender username builder2 .emplace(action.suspiciousUserDisplayName + ":", @@ -2103,8 +2130,23 @@ std::pair TwitchMessageBuilder::makeLowTrustUserMessage( ->setLink({Link::UserInfo, action.suspiciousUserLogin}); // sender's message caught by AutoMod - builder2.emplace(action.text, MessageElementFlag::Text, - MessageColor::Text); + for (const auto &fragment : action.fragments) + { + if (fragment.emoteID.isEmpty()) + { + builder2.emplace( + fragment.text, MessageElementFlag::Text, MessageColor::Text); + } + else + { + const auto emotePtr = + getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{fragment.emoteID}, EmoteName{fragment.text}); + builder2.emplace( + emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text); + } + } + auto text = QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text); builder2.message().messageText = text; diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 9bffd8f7..a9fb15f4 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -95,7 +95,8 @@ public: static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); static std::pair makeLowTrustUserMessage( - const PubSubLowTrustUsersMessage &action, const QString &channelName); + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel); static MessagePtr makeLowTrustUpdateMessage( const PubSubLowTrustUsersMessage &action); @@ -119,7 +120,6 @@ private: void runIgnoreReplaces(std::vector &twitchEmotes); - std::optional getTwitchBadge(const Badge &badge) const; Outcome tryAppendEmote(const EmoteName &name) override; void addWords(const QStringList &words, diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp index 63055894..2a7fd6f5 100644 --- a/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp @@ -21,8 +21,12 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root) { this->msgID = data.value("message_id").toString(); this->sentAt = data.value("sent_at").toString(); - this->text = - data.value("message_content").toObject().value("text").toString(); + const auto content = data.value("message_content").toObject(); + this->text = content.value("text").toString(); + for (const auto &part : content.value("fragments").toArray()) + { + this->fragments.emplace_back(part.toObject()); + } // the rest of the data is within a nested object data = data.value("low_trust_user").toObject(); @@ -35,23 +39,22 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root) this->suspiciousUserColor = QColor(sender.value("chat_color").toString()); - std::vector badges; for (const auto &badge : sender.value("badges").toArray()) { - badges.emplace_back(badge.toObject()); + const auto badgeObj = badge.toObject(); + const auto badgeID = badgeObj.value("id").toString(); + const auto badgeVersion = badgeObj.value("version").toString(); + this->senderBadges.emplace_back(Badge{badgeID, badgeVersion}); } - this->senderBadges = badges; const auto sharedValue = data.value("shared_ban_channel_ids"); - std::vector sharedIDs; if (!sharedValue.isNull()) { for (const auto &id : sharedValue.toArray()) { - sharedIDs.emplace_back(id.toString()); + this->sharedBanChannelIDs.emplace_back(id.toString()); } } - this->sharedBanChannelIDs = sharedIDs; } else { @@ -88,17 +91,15 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root) this->evasionEvaluation = oEvaluation.value(); } - FlagsEnum restrictions; for (const auto &rType : data.value("types").toArray()) { if (const auto oRestriction = magic_enum::enum_cast( rType.toString().toStdString()); oRestriction.has_value()) { - restrictions.set(oRestriction.value()); + this->restrictionTypes.set(oRestriction.value()); } } - this->restrictionTypes = restrictions; } } // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp index 84ca577d..e2666281 100644 --- a/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp @@ -1,5 +1,7 @@ #pragma once +#include "providers/twitch/TwitchBadge.hpp" + #include #include #include @@ -8,18 +10,21 @@ namespace chatterino { -struct LowTrustUserChatBadge { - QString id; - QString version; - - explicit LowTrustUserChatBadge(const QJsonObject &obj) - : id(obj.value("id").toString()) - , version(obj.value("version").toString()) - { - } -}; - struct PubSubLowTrustUsersMessage { + struct Fragment { + QString text; + QString emoteID; + + explicit Fragment(const QJsonObject &obj) + : text(obj.value("text").toString()) + , emoteID(obj.value("emoticon") + .toObject() + .value("emoticonID") + .toString()) + { + } + }; + /** * The type of low trust message update */ @@ -102,6 +107,12 @@ struct PubSubLowTrustUsersMessage { */ QString text; + /** + * Pre-parsed components of the message. + * Only used for the UserMessage type. + */ + std::vector fragments; + /** * ID of the message. * Only used for the UserMessage type. @@ -130,7 +141,7 @@ struct PubSubLowTrustUsersMessage { * A list of badges of the user who sent the message. * Only used for the UserMessage type. */ - std::vector senderBadges; + std::vector senderBadges; /** * Stores the string value of `type`