feat(eventsub): implement automod message hold (#6005)

This commit is contained in:
nerix
2025-03-01 08:32:50 +01:00
committed by GitHub
parent 95b97f42e6
commit 8f1f07d672
17 changed files with 1769 additions and 19 deletions
+16
View File
@@ -1506,6 +1506,22 @@ void TwitchChannel::refreshPubSub()
},
},
});
this->eventSubAutomodMessageHoldHandle =
getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "automod.message.hold",
.subscriptionVersion = "2",
.conditions =
{
{
"broadcaster_user_id",
roomId,
},
{
"moderator_user_id",
currentAccount->getUserId(),
},
},
});
}
else
{
+1
View File
@@ -509,6 +509,7 @@ private:
std::vector<boost::signals2::scoped_connection> bSignals_;
eventsub::SubscriptionHandle eventSubChannelModerateHandle;
eventsub::SubscriptionHandle eventSubAutomodMessageHoldHandle;
friend class TwitchIrcServer;
friend class MessageBuilder;
+52 -3
View File
@@ -3,6 +3,7 @@
#include "Application.hpp"
#include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/highlights/HighlightController.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/twitch/eventsub/MessageBuilder.hpp"
@@ -177,9 +178,57 @@ void Connection::onAutomodMessageHold(
const lib::messages::Metadata &metadata,
const lib::payload::automod_message_hold::v2::Payload &payload)
{
(void)metadata;
qCDebug(LOG) << "On automod message hold for"
<< payload.event.broadcasterUserLogin.c_str();
auto *channel = dynamic_cast<TwitchChannel *>(
getApp()
->getTwitch()
->getChannelOrEmpty(payload.event.broadcasterUserLogin.qt())
.get());
if (!channel || channel->isEmpty())
{
qCDebug(LOG)
<< "Automod message hold for broadcaster we're not interested in"
<< payload.event.broadcasterUserLogin.qt();
return;
}
auto time = chronoToQDateTime(metadata.messageTimestamp);
auto header = makeAutomodHoldMessageHeader(channel, time, payload.event);
auto body = makeAutomodHoldMessageBody(channel, time, payload.event);
auto messageText = payload.event.message.text.qt();
auto userLogin = payload.event.userLogin.qt();
runInGuiThread([channel, messageText, userLogin, header, body] {
auto [highlighted, highlightResult] = getApp()->getHighlights()->check(
{}, {}, userLogin, messageText, body->flags);
if (highlighted)
{
MessageBuilder::triggerHighlights(
channel,
{
.customSound =
highlightResult.customSoundUrl.value_or<QUrl>({}),
.playSound = highlightResult.playSound,
.windowAlert = highlightResult.alert,
});
}
channel->addMessage(header, MessageContext::Original);
channel->addMessage(body, MessageContext::Original);
getApp()->getTwitch()->getAutomodChannel()->addMessage(
header, MessageContext::Original);
getApp()->getTwitch()->getAutomodChannel()->addMessage(
body, MessageContext::Original);
if (getSettings()->showAutomodInMentions)
{
getApp()->getTwitch()->getMentionsChannel()->addMessage(
header, MessageContext::Original);
getApp()->getTwitch()->getMentionsChannel()->addMessage(
body, MessageContext::Original);
}
});
}
void Connection::onAutomodMessageUpdate(
const lib::messages::Metadata &metadata,
@@ -1,8 +1,14 @@
#include "providers/twitch/eventsub/MessageBuilder.hpp"
#include "Application.hpp"
#include "common/Literals.hpp"
#include "messages/Emote.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "singletons/Resources.hpp"
#include "singletons/Settings.hpp"
#include "singletons/StreamerMode.hpp"
#include "util/Helpers.hpp"
namespace {
@@ -33,6 +39,99 @@ void makeModeMessage(EventSubMessageBuilder &builder,
builder.message().searchText = text;
}
QString stringifyAutomodReason(const lib::automod::AutomodReason &reason,
QStringView /* message */)
{
return reason.category.qt() % u" level " % QString::number(reason.level);
}
QString stringifyAutomodReason(const lib::automod::BlockedTermReason &reason,
QStringView message)
{
if (reason.termsFound.empty())
{
return u"blocked term usage"_s;
}
QString msg = [&] {
if (reason.termsFound.size() == 1)
{
return u"matches 1 blocked term"_s;
}
return u"matches %1 blocked terms"_s.arg(reason.termsFound.size());
}();
if (getSettings()->streamerModeHideBlockedTermText &&
getApp()->getStreamerMode()->isEnabled())
{
return msg;
}
for (size_t i = 0; i < reason.termsFound.size(); i++)
{
if (i == 0)
{
msg.append(u" \"");
}
else
{
msg.append(u"\", \"");
}
msg.append(codepointSlice(message,
reason.termsFound[i].boundary.startPos,
reason.termsFound[i].boundary.endPos + 1));
}
msg.append(u'"');
return msg;
}
// XXX: this is a duplicate from messages/MessageBuilder.cpp
EmotePtr makeAutoModBadge()
{
return std::make_shared<Emote>(Emote{
.name = EmoteName{},
.images =
ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)},
.tooltip = Tooltip{"AutoMod"},
.homePage =
Url{"https://dashboard.twitch.tv/settings/moderation/automod"},
});
}
QString localizedDisplayName(
const lib::payload::automod_message_hold::v2::Event &event)
{
QString displayName = event.userName.qt();
bool hasLocalizedName =
displayName.compare(event.userLogin.qt(), Qt::CaseInsensitive) != 0;
switch (getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
if (hasLocalizedName)
{
displayName = event.userLogin.qt();
}
break;
}
case UsernameDisplayMode::LocalizedName: {
break;
}
case UsernameDisplayMode::UsernameAndLocalizedName: {
if (hasLocalizedName)
{
displayName =
event.userLogin.qt() % '(' % event.userName.qt() % ')';
}
break;
}
default:
break;
}
return displayName;
}
} // namespace
namespace chatterino::eventsub {
@@ -47,6 +146,12 @@ EventSubMessageBuilder::EventSubMessageBuilder(TwitchChannel *channel,
this->message().serverReceivedTime = time;
}
EventSubMessageBuilder::EventSubMessageBuilder(TwitchChannel *channel)
: channel(channel)
{
this->message().flags.set(MessageFlag::EventSub);
}
EventSubMessageBuilder::~EventSubMessageBuilder() = default;
void EventSubMessageBuilder::appendUser(const lib::String &userName,
@@ -450,4 +555,95 @@ void makeModerateMessage(
builder.message().searchText = text;
}
MessagePtr makeAutomodHoldMessageHeader(
TwitchChannel *channel, const QDateTime &time,
const lib::payload::automod_message_hold::v2::Event &event)
{
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,
MessageFlag::AutoModOffendingMessageHeader);
builder->flags.set(
MessageFlag::AutoModBlockedTerm,
std::holds_alternative<lib::automod::BlockedTermReason>(event.reason));
// AutoMod shield badge
builder.emplace<BadgeElement>(makeAutoModBadge(),
MessageElementFlag::BadgeChannelAuthority);
// AutoMod "username"
builder.emplace<TextElement>("AutoMod:", MessageElementFlag::Text,
QColor(0, 0, 255), FontStyle::ChatMediumBold);
// AutoMod header message
auto reason = std::visit(
[&](const auto &r) {
return stringifyAutomodReason(r, event.message.text.qt());
},
event.reason);
builder.emplace<TextElement>(u"Held a message for reason: " % reason %
u". Allow will post it in chat. ",
MessageElementFlag::Text, MessageColor::Text);
// Allow link button
builder
.emplace<TextElement>("Allow", MessageElementFlag::Text,
MessageColor(QColor(0, 255, 0)),
FontStyle::ChatMediumBold)
->setLink({Link::AutoModAllow, event.messageID.qt()});
// Deny link button
builder
.emplace<TextElement>(" Deny", MessageElementFlag::Text,
MessageColor(QColor(255, 0, 0)),
FontStyle::ChatMediumBold)
->setLink({Link::AutoModDeny, event.messageID.qt()});
auto text = u"AutoMod: Held a message for reason: " % reason %
u". Allow will post "
"it in chat. Allow Deny";
builder->messageText = text;
builder->searchText = text;
return builder.release();
}
MessagePtr makeAutomodHoldMessageBody(
TwitchChannel *channel, const QDateTime &time,
const lib::payload::automod_message_hold::v2::Event &event)
{
EventSubMessageBuilder builder(channel);
builder->serverReceivedTime = time;
builder->flags.set(MessageFlag::PubSub, MessageFlag::Timeout,
MessageFlag::AutoMod,
MessageFlag::AutoModOffendingMessage);
builder->flags.set(
MessageFlag::AutoModBlockedTerm,
std::holds_alternative<lib::automod::BlockedTermReason>(event.reason));
// Builder for offender's message
builder->channelName = event.broadcasterUserLogin.qt();
builder
.emplace<TextElement>(u'#' + event.broadcasterUserLogin.qt(),
MessageElementFlag::ChannelName,
MessageColor::System)
->setLink({Link::JumpToChannel, event.broadcasterUserLogin.qt()});
builder.emplace<TimestampElement>(time.time());
builder.emplace<TwitchModerationElement>();
builder->loginName = event.userLogin.qt();
auto displayName = localizedDisplayName(event);
// sender username
builder.emplace<MentionElement>(
displayName + ':', event.userLogin.qt(), MessageColor::Text,
channel->getUserColor(event.userLogin.qt()));
// sender's message caught by AutoMod
builder.emplace<TextElement>(event.message.text.qt(),
MessageElementFlag::Text, MessageColor::Text);
auto text = displayName % u": " % event.message.text.qt();
builder->messageText = text;
builder->searchText = text;
return builder.release();
}
} // namespace chatterino::eventsub
@@ -2,6 +2,7 @@
#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-moderate-v2.hpp"
#include <QDateTime>
@@ -20,7 +21,9 @@ namespace chatterino::eventsub {
class EventSubMessageBuilder : public MessageBuilder
{
public:
// builds a system message and adds a timestamp element
EventSubMessageBuilder(TwitchChannel *channel, const QDateTime &time);
EventSubMessageBuilder(TwitchChannel *channel);
~EventSubMessageBuilder();
EventSubMessageBuilder(const EventSubMessageBuilder &) = delete;
@@ -145,4 +148,12 @@ void makeModerateMessage(
const lib::payload::channel_moderate::v2::Event &event,
const lib::payload::channel_moderate::v2::Unraid &action);
MessagePtr makeAutomodHoldMessageHeader(
TwitchChannel *channel, const QDateTime &time,
const lib::payload::automod_message_hold::v2::Event &event);
MessagePtr makeAutomodHoldMessageBody(
TwitchChannel *channel, const QDateTime &time,
const lib::payload::automod_message_hold::v2::Event &event);
} // namespace chatterino::eventsub
+35
View File
@@ -333,4 +333,39 @@ QDateTime chronoToQDateTime(std::chrono::system_clock::time_point time)
return dt;
}
QStringView codepointSlice(QStringView str, qsizetype begin, qsizetype end)
{
if (end <= begin || begin < 0)
{
return {};
}
qsizetype n = 0;
const QChar *pos = str.begin();
const QChar *endPos = str.end();
const QChar *sliceBegin = nullptr;
while (n < end)
{
if (pos >= endPos)
{
return {};
}
if (n == begin)
{
sliceBegin = pos;
}
QChar cur = *pos++;
if (cur.isHighSurrogate() && pos < endPos && pos->isLowSurrogate())
{
pos++;
}
n++;
}
assert(pos <= endPos);
return {sliceBegin, pos};
}
} // namespace chatterino
+9
View File
@@ -199,4 +199,13 @@ QLocale getSystemLocale();
/// Note: When running tests, this will always return a date-time in UTC.
QDateTime chronoToQDateTime(std::chrono::system_clock::time_point time);
/// Slices a string based on codepoint indices.
///
/// If the specified range is outside the string, an empty string view is
/// returned.
///
/// @param begin Start index (inclusive, in codepoints)
/// @param end End index (exclusive, in codepoints)
QStringView codepointSlice(QStringView str, qsizetype begin, qsizetype end);
} // namespace chatterino