diff --git a/CHANGELOG.md b/CHANGELOG.md index f00312f7d..31305fb88 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 81b604cf0..e13624c7e 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 9f31ec1e6..7ae61991a 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 6ebfdb944..c8afbd769 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 567c8d132..2fbc5fd95 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 30c1308a4..82de23fe6 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 c15257699..76e32de72 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 0035f52c3..47fcf93f2 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 9bffd8f7a..a9fb15f4f 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 630558944..2a7fd6f50 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 84ca577d7..e26662813 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`