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 <rasmus.karlsson@pajlada.com>
This commit is contained in:
iProdigy 2024-01-06 07:22:00 -06:00 committed by GitHub
parent 416806bb0a
commit 693d4f401d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 215 additions and 133 deletions

View file

@ -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)

View file

@ -525,12 +525,20 @@ void Application::initPubSub()
return;
}
postToThread([chan, action] {
auto twitchChannel =
std::dynamic_pointer_cast<TwitchChannel>(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);
});
});

View file

@ -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()},

View file

@ -44,6 +44,8 @@ static const QMap<QString, Type> 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},
};

View file

@ -32,6 +32,8 @@ static const QMap<QString, QString> 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"}};

View file

@ -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<MessageFlag>;

View file

@ -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);
}
}
}

View file

@ -153,6 +153,119 @@ namespace {
}
}
std::optional<EmotePtr> 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<Badge> &badges,
const std::unordered_map<QString, QString> &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<ModBadgeElement>(
*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<VipBadgeElement>(
*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<BadgeElement>(*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<EmotePtr> 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<QString, QString> 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<ModBadgeElement>(
*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<VipBadgeElement>(
*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<BadgeElement>(*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<MessagePtr, MessagePtr> 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<TimestampElement>();
builder.message().flags.set(MessageFlag::System);
@ -2006,7 +2027,8 @@ MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage(
}
std::pair<MessagePtr, MessagePtr> 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<MessagePtr, MessagePtr> 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<MessagePtr, MessagePtr> 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<TextElement>(action.suspiciousUserDisplayName + ":",
@ -2103,8 +2130,23 @@ std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeLowTrustUserMessage(
->setLink({Link::UserInfo, action.suspiciousUserLogin});
// sender's message caught by AutoMod
builder2.emplace<TextElement>(action.text, MessageElementFlag::Text,
MessageColor::Text);
for (const auto &fragment : action.fragments)
{
if (fragment.emoteID.isEmpty())
{
builder2.emplace<TextElement>(
fragment.text, MessageElementFlag::Text, MessageColor::Text);
}
else
{
const auto emotePtr =
getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
EmoteId{fragment.emoteID}, EmoteName{fragment.text});
builder2.emplace<EmoteElement>(
emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text);
}
}
auto text =
QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text);
builder2.message().messageText = text;

View file

@ -95,7 +95,8 @@ public:
static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action);
static std::pair<MessagePtr, MessagePtr> 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<TwitchEmoteOccurrence> &twitchEmotes);
std::optional<EmotePtr> getTwitchBadge(const Badge &badge) const;
Outcome tryAppendEmote(const EmoteName &name) override;
void addWords(const QStringList &words,

View file

@ -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<LowTrustUserChatBadge> 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<QString> 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<RestrictionType> restrictions;
for (const auto &rType : data.value("types").toArray())
{
if (const auto oRestriction = magic_enum::enum_cast<RestrictionType>(
rType.toString().toStdString());
oRestriction.has_value())
{
restrictions.set(oRestriction.value());
this->restrictionTypes.set(oRestriction.value());
}
}
this->restrictionTypes = restrictions;
}
} // namespace chatterino

View file

@ -1,5 +1,7 @@
#pragma once
#include "providers/twitch/TwitchBadge.hpp"
#include <common/FlagsEnum.hpp>
#include <magic_enum/magic_enum.hpp>
#include <QColor>
@ -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<Fragment> 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<LowTrustUserChatBadge> senderBadges;
std::vector<Badge> senderBadges;
/**
* Stores the string value of `type`