From f4726ed7a8fadd0b10ecdf5107506b7d54e43364 Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 31 Oct 2023 17:47:56 +0100 Subject: [PATCH] refactor: IrcMessageHandler (#4927) --- CHANGELOG.md | 1 + src/providers/recentmessages/Impl.cpp | 5 +- src/providers/twitch/IrcMessageHandler.cpp | 1135 ++++++++++---------- src/providers/twitch/IrcMessageHandler.hpp | 44 +- 4 files changed, 608 insertions(+), 577 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a22a92978..4b34ffec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921) - Dev: Changed lifetime of context menus. (#4924) - Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926) +- Dev: Refactor `IrcMessageHandler`, removing a bunch of clang-tidy warnings & changing its public API. (#4927) ## 2.4.6 diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp index 9a826811c..36a4b4c99 100644 --- a/src/providers/recentmessages/Impl.cpp +++ b/src/providers/recentmessages/Impl.cpp @@ -54,7 +54,6 @@ std::vector parseRecentMessages( std::vector buildRecentMessages( std::vector &messages, Channel *channel) { - auto &handler = IrcMessageHandler::instance(); std::vector allBuiltMessages; for (auto *message : messages) @@ -78,8 +77,8 @@ std::vector buildRecentMessages( } } - auto builtMessages = - handler.parseMessageWithReply(channel, message, allBuiltMessages); + auto builtMessages = IrcMessageHandler::parseMessageWithReply( + channel, message, allBuiltMessages); for (const auto &builtMessage : builtMessages) { diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 38c15f289..5018cb6ca 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -35,11 +35,14 @@ #include #include +using namespace chatterino::literals; + namespace { + using namespace chatterino; // Message types below are the ones that might contain special user's message on USERNOTICE -static const QSet specialMessageTypes{ +const QSet SPECIAL_MESSAGE_TYPES{ "sub", // "subgift", // "resub", // resub messages @@ -173,12 +176,7 @@ ChannelPtr channelOrEmptyByTarget(const QString &target, return server.getChannelOrEmpty(channelName); } -} // namespace -namespace chatterino { - -using namespace literals; - -static float relativeSimilarity(const QString &str1, const QString &str2) +float relativeSimilarity(const QString &str1, const QString &str2) { // Longest Common Substring Problem std::vector> tree(str1.size(), @@ -212,67 +210,17 @@ static float relativeSimilarity(const QString &str1, const QString &str2) } // ensure that no div by 0 - return z == 0 ? 0.f - : float(z) / - std::max(1, std::max(str1.size(), str2.size())); -}; - -float IrcMessageHandler::similarity( - MessagePtr msg, const LimitedQueueSnapshot &messages) -{ - float similarityPercent = 0.0f; - int checked = 0; - for (int i = 1; i <= messages.size(); ++i) + if (z == 0) { - if (checked >= getSettings()->hideSimilarMaxMessagesToCheck) - { - break; - } - const auto &prevMsg = messages[messages.size() - i]; - if (prevMsg->parseTime.secsTo(QTime::currentTime()) >= - getSettings()->hideSimilarMaxDelay) - { - break; - } - if (getSettings()->hideSimilarBySameUser && - msg->loginName != prevMsg->loginName) - { - continue; - } - ++checked; - similarityPercent = std::max( - similarityPercent, - relativeSimilarity(msg->messageText, prevMsg->messageText)); + return 0.F; } - return similarityPercent; + + auto div = std::max(1, std::max(str1.size(), str2.size())); + + return float(z) / float(div); } -void IrcMessageHandler::setSimilarityFlags(MessagePtr msg, ChannelPtr chan) -{ - if (getSettings()->similarityEnabled) - { - bool isMyself = msg->loginName == - getApp()->accounts->twitch.getCurrent()->getUserName(); - bool hideMyself = getSettings()->hideSimilarMyself; - - if (isMyself && !hideMyself) - { - return; - } - - if (IrcMessageHandler::similarity(msg, chan->getMessageSnapshot()) > - getSettings()->similarityPercentage) - { - msg->flags.set(MessageFlag::Similar, true); - if (getSettings()->colorSimilarDisabled) - { - msg->flags.set(MessageFlag::Disabled, true); - } - } - } -} - -static QMap parseBadges(QString badgesString) +QMap parseBadges(const QString &badgesString) { QMap badges; @@ -290,174 +238,16 @@ static QMap parseBadges(QString badgesString) return badges; } -IrcMessageHandler &IrcMessageHandler::instance() -{ - static IrcMessageHandler instance; - return instance; -} - -std::vector IrcMessageHandler::parseMessage( - Channel *channel, Communi::IrcMessage *message) -{ - std::vector builtMessages; - - auto command = message->command(); - - if (command == "PRIVMSG") - { - return this->parsePrivMessage( - channel, static_cast(message)); - } - else if (command == "USERNOTICE") - { - return this->parseUserNoticeMessage(channel, message); - } - else if (command == "NOTICE") - { - return this->parseNoticeMessage( - static_cast(message)); - } - - return builtMessages; -} - -std::vector IrcMessageHandler::parsePrivMessage( - Channel *channel, Communi::IrcPrivateMessage *message) -{ - std::vector builtMessages; - MessageParseArgs args; - TwitchMessageBuilder builder(channel, message, args, message->content(), - message->isAction()); - if (!builder.isIgnored()) - { - builtMessages.emplace_back(builder.build()); - builder.triggerHighlights(); - } - - if (message->tags().contains(u"pinned-chat-paid-amount"_s)) - { - auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); - if (ptr) - { - builtMessages.emplace_back(std::move(ptr)); - } - } - - return builtMessages; -} - -void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, - TwitchIrcServer &server) -{ - // This is for compatibility with older Chatterino versions. Twitch didn't use - // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG - // instead. - // See https://github.com/Chatterino/chatterino2/issues/3384 and - // https://mm2pl.github.io/emoji_rfc.pdf for more details - - this->addMessage( - message, message->target(), - message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, - false, message->isAction()); - - auto chan = channelOrEmptyByTarget(message->target(), server); - if (chan->isEmpty()) - { - return; - } - - if (message->tags().contains(u"pinned-chat-paid-amount"_s)) - { - auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); - if (ptr) - { - chan->addMessage(ptr); - } - } -} - -std::vector IrcMessageHandler::parseMessageWithReply( - Channel *channel, Communi::IrcMessage *message, - std::vector &otherLoaded) -{ - std::vector builtMessages; - - auto command = message->command(); - - if (command == "PRIVMSG") - { - auto privMsg = static_cast(message); - auto tc = dynamic_cast(channel); - if (!tc) - { - return this->parsePrivMessage(channel, privMsg); - } - - QString content = privMsg->content(); - int messageOffset = stripLeadingReplyMention(privMsg->tags(), content); - MessageParseArgs args; - TwitchMessageBuilder builder(channel, message, args, content, - privMsg->isAction()); - builder.setMessageOffset(messageOffset); - - this->populateReply(tc, message, otherLoaded, builder); - - if (!builder.isIgnored()) - { - builtMessages.emplace_back(builder.build()); - builder.triggerHighlights(); - } - } - else if (command == "USERNOTICE") - { - return this->parseUserNoticeMessage(channel, message); - } - else if (command == "NOTICE") - { - return this->parseNoticeMessage( - static_cast(message)); - } - else if (command == u"CLEARCHAT"_s) - { - auto cc = this->parseClearChatMessage(message); - if (!cc) - { - return builtMessages; - } - auto &clearChat = *cc; - if (clearChat.disableAllMessages) - { - builtMessages.emplace_back(std::move(clearChat.message)); - } - else - { - addOrReplaceChannelTimeout( - otherLoaded, std::move(clearChat.message), - calculateMessageTime(message).time(), - [&](auto idx, auto /*msg*/, auto &&replacement) { - replacement->flags.set(MessageFlag::RecentMessage); - otherLoaded[idx] = replacement; - }, - [&](auto &&msg) { - builtMessages.emplace_back(msg); - }, - false); - } - } - - return builtMessages; -} - -void IrcMessageHandler::populateReply( - TwitchChannel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded, TwitchMessageBuilder &builder) +void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded, + TwitchMessageBuilder &builder) { const auto &tags = message->tags(); if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) { const QString replyID = it.value().toString(); - auto threadIt = channel->threads_.find(replyID); - if (threadIt != channel->threads_.end()) + auto threadIt = channel->threads().find(replyID); + if (threadIt != channel->threads().end()) { auto owned = threadIt->second.lock(); if (owned) @@ -474,7 +264,7 @@ void IrcMessageHandler::populateReply( // Thread does not yet exist, find root reply and create thread. // Linear search is justified by the infrequent use of replies - for (auto &otherMsg : otherLoaded) + for (const auto &otherMsg : otherLoaded) { if (otherMsg->id == replyID) { @@ -507,129 +297,377 @@ void IrcMessageHandler::populateReply( } } -void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, - const QString &target, - const QString &content_, - TwitchIrcServer &server, bool isSub, - bool isAction) +std::optional parseClearChatMessage( + Communi::IrcMessage *message) { - auto chan = channelOrEmptyByTarget(target, server); + // check parameter count + if (message->parameters().length() < 1) + { + return std::nullopt; + } + // check if the chat has been cleared by a moderator + if (message->parameters().length() == 1) + { + return ClearChatMessage{ + .message = + makeSystemMessage("Chat has been cleared by a moderator.", + calculateMessageTime(message).time()), + .disableAllMessages = true, + }; + } + + // get username, duration and message of the timed out user + QString username = message->parameter(1); + QString durationInSeconds; + QVariant v = message->tag("ban-duration"); + if (v.isValid()) + { + durationInSeconds = v.toString(); + } + + auto timeoutMsg = + MessageBuilder(timeoutMessage, username, durationInSeconds, false, + calculateMessageTime(message).time()) + .release(); + + return ClearChatMessage{.message = timeoutMsg, .disableAllMessages = false}; +} + +/** + * Parse a single IRC NOTICE message into 0 or more Chatterino messages + **/ +std::vector parseNoticeMessage(Communi::IrcNoticeMessage *message) +{ + assert(message != nullptr); + + if (message->content().startsWith("Login auth", Qt::CaseInsensitive)) + { + const auto linkColor = MessageColor(MessageColor::Link); + const auto accountsLink = Link(Link::OpenAccountsPage, QString()); + const auto curUser = getApp()->accounts->twitch.getCurrent(); + const auto expirationText = QString("Login expired for user \"%1\"!") + .arg(curUser->getUserName()); + const auto loginPromptText = QString("Try adding your account again."); + + MessageBuilder builder; + auto text = QString("%1 %2").arg(expirationText, loginPromptText); + builder.message().messageText = text; + builder.message().searchText = text; + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder.emplace(); + builder.emplace(expirationText, MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(loginPromptText, MessageElementFlag::Text, + linkColor) + ->setLink(accountsLink); + + return {builder.release()}; + } + + if (message->content().startsWith("You are permanently banned ")) + { + return {generateBannedMessage(true)}; + } + + if (message->tags().value("msg-id") == "msg_timedout") + { + std::vector builtMessage; + + QString remainingTime = + formatTime(message->content().split(" ").value(5)); + QString formattedMessage = + QString("You are timed out for %1.") + .arg(remainingTime.isEmpty() ? "0s" : remainingTime); + + builtMessage.emplace_back(makeSystemMessage( + formattedMessage, calculateMessageTime(message).time())); + + return builtMessage; + } + + // default case + std::vector builtMessages; + + auto content = message->content(); + if (content.startsWith( + "Your settings prevent you from sending this whisper", + Qt::CaseInsensitive) && + getSettings()->helixTimegateWhisper.getValue() == + HelixTimegateOverride::Timegate) + { + content = content + + " Consider setting \"Helix timegate /w behaviour\" " + "to \"Always use Helix\" in your Chatterino settings."; + } + builtMessages.emplace_back( + makeSystemMessage(content, calculateMessageTime(message).time())); + + return builtMessages; +} + +/** + * Parse a single IRC USERNOTICE message into 0 or more Chatterino messages + **/ +std::vector parseUserNoticeMessage(Channel *channel, + Communi::IrcMessage *message) +{ + assert(channel != nullptr); + assert(message != nullptr); + + std::vector builtMessages; + + auto tags = message->tags(); + auto parameters = message->parameters(); + + QString msgType = tags.value("msg-id").toString(); + QString content; + if (parameters.size() >= 2) + { + content = parameters[1]; + } + + if (isIgnoredMessage({ + .message = content, + .twitchUserID = tags.value("user-id").toString(), + .isMod = channel->isMod(), + .isBroadcaster = channel->isBroadcaster(), + })) + { + return {}; + } + + if (SPECIAL_MESSAGE_TYPES.contains(msgType)) + { + // Messages are not required, so they might be empty + if (!content.isEmpty()) + { + MessageParseArgs args; + args.trimSubscriberUsername = true; + + TwitchMessageBuilder builder(channel, message, args, content, + false); + builder->flags.set(MessageFlag::Subscription); + builder->flags.unset(MessageFlag::Highlighted); + builtMessages.emplace_back(builder.build()); + } + } + + auto it = tags.find("system-msg"); + + if (it != tags.end()) + { + // By default, we return value of system-msg tag + QString messageText = it.value().toString(); + + if (msgType == "bitsbadgetier") + { + messageText = + QString("%1 just earned a new %2 Bits badge!") + .arg(tags.value("display-name").toString(), + kFormatNumbers( + tags.value("msg-param-threshold").toInt())); + } + else if (msgType == "announcement") + { + messageText = "Announcement"; + } + + auto b = MessageBuilder(systemMessage, parseTagString(messageText), + calculateMessageTime(message).time()); + + b->flags.set(MessageFlag::Subscription); + auto newMessage = b.release(); + builtMessages.emplace_back(newMessage); + } + + return builtMessages; +} + +/** + * Parse a single IRC PRIVMSG into 0-1 Chatterino messages + */ +std::vector parsePrivMessage(Channel *channel, + Communi::IrcPrivateMessage *message) +{ + assert(channel != nullptr); + assert(message != nullptr); + + std::vector builtMessages; + MessageParseArgs args; + TwitchMessageBuilder builder(channel, message, args, message->content(), + message->isAction()); + if (!builder.isIgnored()) + { + builtMessages.emplace_back(builder.build()); + builder.triggerHighlights(); + } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + builtMessages.emplace_back(std::move(ptr)); + } + } + + return builtMessages; +} + +/** + * Parse a single IRC message into 0 or more Chatterino messages + **/ +std::vector parseMessage(Channel *channel, + Communi::IrcMessage *message) +{ + assert(channel != nullptr); + assert(message != nullptr); + + std::vector builtMessages; + + auto command = message->command(); + + if (command == "PRIVMSG") + { + return parsePrivMessage( + channel, dynamic_cast(message)); + } + + if (command == "USERNOTICE") + { + return parseUserNoticeMessage(channel, message); + } + + if (command == "NOTICE") + { + return parseNoticeMessage( + dynamic_cast(message)); + } + + return builtMessages; +} + +} // namespace + +namespace chatterino { + +using namespace literals; + +IrcMessageHandler &IrcMessageHandler::instance() +{ + static IrcMessageHandler instance; + return instance; +} + +std::vector IrcMessageHandler::parseMessageWithReply( + Channel *channel, Communi::IrcMessage *message, + std::vector &otherLoaded) +{ + std::vector builtMessages; + + auto command = message->command(); + + if (command == u"PRIVMSG"_s) + { + auto *privMsg = dynamic_cast(message); + auto *tc = dynamic_cast(channel); + if (!tc) + { + return parsePrivMessage(channel, privMsg); + } + + QString content = privMsg->content(); + int messageOffset = stripLeadingReplyMention(privMsg->tags(), content); + MessageParseArgs args; + TwitchMessageBuilder builder(channel, message, args, content, + privMsg->isAction()); + builder.setMessageOffset(messageOffset); + + populateReply(tc, message, otherLoaded, builder); + + if (!builder.isIgnored()) + { + builtMessages.emplace_back(builder.build()); + builder.triggerHighlights(); + } + + return builtMessages; + } + + if (command == u"USERNOTICE"_s) + { + return parseUserNoticeMessage(channel, message); + } + + if (command == u"NOTICE"_s) + { + return parseNoticeMessage( + dynamic_cast(message)); + } + + if (command == u"CLEARCHAT"_s) + { + auto cc = parseClearChatMessage(message); + if (!cc) + { + return builtMessages; + } + auto &clearChat = *cc; + if (clearChat.disableAllMessages) + { + builtMessages.emplace_back(std::move(clearChat.message)); + } + else + { + addOrReplaceChannelTimeout( + otherLoaded, std::move(clearChat.message), + calculateMessageTime(message).time(), + [&](auto idx, auto /*msg*/, auto &&replacement) { + replacement->flags.set(MessageFlag::RecentMessage); + otherLoaded[idx] = replacement; + }, + [&](auto &&msg) { + builtMessages.emplace_back(msg); + }, + false); + } + + return builtMessages; + } + + return builtMessages; +} + +void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, + TwitchIrcServer &server) +{ + // This is for compatibility with older Chatterino versions. Twitch didn't use + // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG + // instead. + // See https://github.com/Chatterino/chatterino2/issues/3384 and + // https://mm2pl.github.io/emoji_rfc.pdf for more details + + this->addMessage( + message, message->target(), + message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, + false, message->isAction()); + + auto chan = channelOrEmptyByTarget(message->target(), server); if (chan->isEmpty()) { return; } - MessageParseArgs args; - if (isSub) + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { - args.isSubscriptionMessage = true; - args.trimSubscriberUsername = true; - } - - if (chan->isBroadcaster()) - { - args.isStaffOrBroadcaster = true; - } - - auto channel = dynamic_cast(chan.get()); - - const auto &tags = _message->tags(); - if (const auto it = tags.find("custom-reward-id"); it != tags.end()) - { - const auto rewardId = it.value().toString(); - if (!channel->isChannelPointRewardKnown(rewardId)) + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) { - // Need to wait for pubsub reward notification - auto clone = _message->clone(); - qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " - "callback since reward is not known:" - << rewardId; - channel->channelPointRewardAdded.connect( - [=, this, &server](ChannelPointReward reward) { - qCDebug(chatterinoTwitch) - << "TwitchChannel reward added callback:" << reward.id - << "-" << rewardId; - if (reward.id == rewardId) - { - this->addMessage(clone, target, content_, server, isSub, - isAction); - clone->deleteLater(); - return true; - } - return false; - }); - return; - } - args.channelPointRewardId = rewardId; - } - - QString content = content_; - int messageOffset = stripLeadingReplyMention(tags, content); - - TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction); - builder.setMessageOffset(messageOffset); - - if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) - { - const QString replyID = it.value().toString(); - auto threadIt = channel->threads_.find(replyID); - if (threadIt != channel->threads_.end() && !threadIt->second.expired()) - { - // Thread already exists (has a reply) - auto thread = threadIt->second.lock(); - updateReplyParticipatedStatus(tags, _message->nick(), builder, - thread, false); - builder.setThread(thread); - } - else - { - // Thread does not yet exist, find root reply and create thread. - auto root = channel->findMessage(replyID); - if (root) - { - // Found root reply message - auto newThread = std::make_shared(root); - updateReplyParticipatedStatus(tags, _message->nick(), builder, - newThread, true); - - builder.setThread(newThread); - // Store weak reference to thread in channel - channel->addReplyThread(newThread); - } - } - } - - if (isSub || !builder.isIgnored()) - { - if (isSub) - { - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); - } - auto msg = builder.build(); - - IrcMessageHandler::setSimilarityFlags(msg, chan); - - if (!msg->flags.has(MessageFlag::Similar) || - (!getSettings()->hideSimilar && - getSettings()->shownSimilarTriggerHighlights)) - { - builder.triggerHighlights(); - } - - const auto highlighted = msg->flags.has(MessageFlag::Highlighted); - const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions); - - if (highlighted && showInMentions) - { - server.mentionsChannel->addMessage(msg); - } - - chan->addMessage(msg); - if (auto chatters = dynamic_cast(chan.get())) - { - chatters->addRecentChatter(msg->displayName); + chan->addMessage(ptr); } } } @@ -690,46 +728,9 @@ void IrcMessageHandler::handleRoomStateMessage(Communi::IrcMessage *message) twitchChannel->roomModesChanged.invoke(); } -std::optional IrcMessageHandler::parseClearChatMessage( - Communi::IrcMessage *message) -{ - // check parameter count - if (message->parameters().length() < 1) - { - return std::nullopt; - } - - // check if the chat has been cleared by a moderator - if (message->parameters().length() == 1) - { - return ClearChatMessage{ - .message = - makeSystemMessage("Chat has been cleared by a moderator.", - calculateMessageTime(message).time()), - .disableAllMessages = true, - }; - } - - // get username, duration and message of the timed out user - QString username = message->parameter(1); - QString durationInSeconds; - QVariant v = message->tag("ban-duration"); - if (v.isValid()) - { - durationInSeconds = v.toString(); - } - - auto timeoutMsg = - MessageBuilder(timeoutMessage, username, durationInSeconds, false, - calculateMessageTime(message).time()) - .release(); - - return ClearChatMessage{.message = timeoutMsg, .disableAllMessages = false}; -} - void IrcMessageHandler::handleClearChatMessage(Communi::IrcMessage *message) { - auto cc = this->parseClearChatMessage(message); + auto cc = parseClearChatMessage(message); if (!cc) { return; @@ -804,7 +805,9 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) auto msg = chan->findMessage(targetID); if (msg == nullptr) + { return; + } msg->flags.set(MessageFlag::Disabled); if (!getSettings()->hideDeletionActions) @@ -841,26 +844,26 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) } // Checking if currentUser is a VIP or staff member - QVariant _badges = message->tag("badges"); - if (_badges.isValid()) + QVariant badgesTag = message->tag("badges"); + if (badgesTag.isValid()) { - TwitchChannel *tc = dynamic_cast(c.get()); + auto *tc = dynamic_cast(c.get()); if (tc != nullptr) { - auto parsedBadges = parseBadges(_badges.toString()); + auto parsedBadges = parseBadges(badgesTag.toString()); tc->setVIP(parsedBadges.contains("vip")); tc->setStaff(parsedBadges.contains("staff")); } } // Checking if currentUser is a moderator - QVariant _mod = message->tag("mod"); - if (_mod.isValid()) + QVariant modTag = message->tag("mod"); + if (modTag.isValid()) { - TwitchChannel *tc = dynamic_cast(c.get()); + auto *tc = dynamic_cast(c.get()); if (tc != nullptr) { - tc->setMod(_mod == "1"); + tc->setMod(modTag == "1"); } } } @@ -883,17 +886,17 @@ void IrcMessageHandler::handleGlobalUserStateMessage( currentUser->loadEmotes(); } -void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) +void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) { MessageParseArgs args; args.isReceivedWhisper = true; - auto c = getApp()->twitch->whispersChannel.get(); + auto *c = getApp()->twitch->whispersChannel.get(); TwitchMessageBuilder builder( - c, message, args, - message->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), + c, ircMessage, args, + ircMessage->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), false); if (builder.isIgnored()) @@ -902,19 +905,19 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) } builder->flags.set(MessageFlag::Whisper); - MessagePtr _message = builder.build(); + MessagePtr message = builder.build(); builder.triggerHighlights(); getApp()->twitch->lastUserThatWhisperedMe.set(builder.userName); - if (_message->flags.has(MessageFlag::ShowInMentions)) + if (message->flags.has(MessageFlag::ShowInMentions)) { - getApp()->twitch->mentionsChannel->addMessage(_message); + getApp()->twitch->mentionsChannel->addMessage(message); } - c->addMessage(_message); + c->addMessage(message); - auto overrideFlags = std::optional(_message->flags); + auto overrideFlags = std::optional(message->flags); overrideFlags->set(MessageFlag::DoNotTriggerNotification); overrideFlags->set(MessageFlag::DoNotLog); @@ -923,84 +926,12 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) isInStreamerMode())) { getApp()->twitch->forEachChannel( - [&_message, overrideFlags](ChannelPtr channel) { - channel->addMessage(_message, overrideFlags); + [&message, overrideFlags](ChannelPtr channel) { + channel->addMessage(message, overrideFlags); }); } } -std::vector IrcMessageHandler::parseUserNoticeMessage( - Channel *channel, Communi::IrcMessage *message) -{ - std::vector builtMessages; - - auto tags = message->tags(); - auto parameters = message->parameters(); - - QString msgType = tags.value("msg-id").toString(); - QString content; - if (parameters.size() >= 2) - { - content = parameters[1]; - } - - if (isIgnoredMessage({ - .message = content, - .twitchUserID = tags.value("user-id").toString(), - .isMod = channel->isMod(), - .isBroadcaster = channel->isBroadcaster(), - })) - { - return {}; - } - - if (specialMessageTypes.contains(msgType)) - { - // Messages are not required, so they might be empty - if (!content.isEmpty()) - { - MessageParseArgs args; - args.trimSubscriberUsername = true; - - TwitchMessageBuilder builder(channel, message, args, content, - false); - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); - builtMessages.emplace_back(builder.build()); - } - } - - auto it = tags.find("system-msg"); - - if (it != tags.end()) - { - // By default, we return value of system-msg tag - QString messageText = it.value().toString(); - - if (msgType == "bitsbadgetier") - { - messageText = - QString("%1 just earned a new %2 Bits badge!") - .arg(tags.value("display-name").toString(), - kFormatNumbers( - tags.value("msg-param-threshold").toInt())); - } - else if (msgType == "announcement") - { - messageText = "Announcement"; - } - - auto b = MessageBuilder(systemMessage, parseTagString(messageText), - calculateMessageTime(message).time()); - - b->flags.set(MessageFlag::Subscription); - auto newMessage = b.release(); - builtMessages.emplace_back(newMessage); - } - - return builtMessages; -} - void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, TwitchIrcServer &server) { @@ -1026,7 +957,7 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, return; } - if (specialMessageTypes.contains(msgType)) + if (SPECIAL_MESSAGE_TYPES.contains(msgType)) { // Messages are not required, so they might be empty if (!content.isEmpty()) @@ -1082,78 +1013,9 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, } } -std::vector IrcMessageHandler::parseNoticeMessage( - Communi::IrcNoticeMessage *message) -{ - if (message->content().startsWith("Login auth", Qt::CaseInsensitive)) - { - const auto linkColor = MessageColor(MessageColor::Link); - const auto accountsLink = Link(Link::OpenAccountsPage, QString()); - const auto curUser = getApp()->accounts->twitch.getCurrent(); - const auto expirationText = QString("Login expired for user \"%1\"!") - .arg(curUser->getUserName()); - const auto loginPromptText = QString("Try adding your account again."); - - MessageBuilder builder; - auto text = QString("%1 %2").arg(expirationText, loginPromptText); - builder.message().messageText = text; - builder.message().searchText = text; - builder.message().flags.set(MessageFlag::System); - builder.message().flags.set(MessageFlag::DoNotTriggerNotification); - - builder.emplace(); - builder.emplace(expirationText, MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(loginPromptText, MessageElementFlag::Text, - linkColor) - ->setLink(accountsLink); - - return {builder.release()}; - } - else if (message->content().startsWith("You are permanently banned ")) - { - return {generateBannedMessage(true)}; - } - else if (message->tags().value("msg-id") == "msg_timedout") - { - std::vector builtMessage; - - QString remainingTime = - formatTime(message->content().split(" ").value(5)); - QString formattedMessage = - QString("You are timed out for %1.") - .arg(remainingTime.isEmpty() ? "0s" : remainingTime); - - builtMessage.emplace_back(makeSystemMessage( - formattedMessage, calculateMessageTime(message).time())); - - return builtMessage; - } - - // default case - std::vector builtMessages; - - auto content = message->content(); - if (content.startsWith( - "Your settings prevent you from sending this whisper", - Qt::CaseInsensitive) && - getSettings()->helixTimegateWhisper.getValue() == - HelixTimegateOverride::Timegate) - { - content = content + - " Consider setting \"Helix timegate /w behaviour\" " - "to \"Always use Helix\" in your Chatterino settings."; - } - builtMessages.emplace_back( - makeSystemMessage(content, calculateMessageTime(message).time())); - - return builtMessages; -} - void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) { - auto builtMessages = this->parseNoticeMessage(message); + auto builtMessages = parseNoticeMessage(message); for (const auto &msg : builtMessages) { @@ -1232,7 +1094,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) QStringList msgParts = noticeText.split(':'); MessageBuilder builder; - auto tc = dynamic_cast(channel.get()); + auto *tc = dynamic_cast(channel.get()); assert(tc != nullptr && "IrcMessageHandler::handleNoticeMessage. Twitch specific " "functionality called in non twitch channel"); @@ -1298,4 +1160,191 @@ void IrcMessageHandler::handlePartMessage(Communi::IrcMessage *message) channel->addMessage(generateBannedMessage(false)); } } + +float IrcMessageHandler::similarity( + const MessagePtr &msg, const LimitedQueueSnapshot &messages) +{ + float similarityPercent = 0.0F; + int checked = 0; + + for (int i = 1; i <= messages.size(); ++i) + { + if (checked >= getSettings()->hideSimilarMaxMessagesToCheck) + { + break; + } + const auto &prevMsg = messages[messages.size() - i]; + if (prevMsg->parseTime.secsTo(QTime::currentTime()) >= + getSettings()->hideSimilarMaxDelay) + { + break; + } + if (getSettings()->hideSimilarBySameUser && + msg->loginName != prevMsg->loginName) + { + continue; + } + ++checked; + similarityPercent = std::max( + similarityPercent, + relativeSimilarity(msg->messageText, prevMsg->messageText)); + } + + return similarityPercent; +} + +void IrcMessageHandler::setSimilarityFlags(const MessagePtr &message, + const ChannelPtr &channel) +{ + if (getSettings()->similarityEnabled) + { + bool isMyself = message->loginName == + getApp()->accounts->twitch.getCurrent()->getUserName(); + bool hideMyself = getSettings()->hideSimilarMyself; + + if (isMyself && !hideMyself) + { + return; + } + + if (IrcMessageHandler::similarity(message, + channel->getMessageSnapshot()) > + getSettings()->similarityPercentage) + { + message->flags.set(MessageFlag::Similar, true); + if (getSettings()->colorSimilarDisabled) + { + message->flags.set(MessageFlag::Disabled, true); + } + } + } +} + +void IrcMessageHandler::addMessage(Communi::IrcMessage *message, + const QString &target, + const QString &originalContent, + TwitchIrcServer &server, bool isSub, + bool isAction) +{ + auto chan = channelOrEmptyByTarget(target, server); + + if (chan->isEmpty()) + { + return; + } + + MessageParseArgs args; + if (isSub) + { + args.isSubscriptionMessage = true; + args.trimSubscriberUsername = true; + } + + if (chan->isBroadcaster()) + { + args.isStaffOrBroadcaster = true; + } + + auto *channel = dynamic_cast(chan.get()); + + const auto &tags = message->tags(); + if (const auto it = tags.find("custom-reward-id"); it != tags.end()) + { + const auto rewardId = it.value().toString(); + if (!channel->isChannelPointRewardKnown(rewardId)) + { + // Need to wait for pubsub reward notification + auto *clone = message->clone(); + qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " + "callback since reward is not known:" + << rewardId; + channel->channelPointRewardAdded.connect( + [=, this, &server](ChannelPointReward reward) { + qCDebug(chatterinoTwitch) + << "TwitchChannel reward added callback:" << reward.id + << "-" << rewardId; + if (reward.id == rewardId) + { + this->addMessage(clone, target, originalContent, server, + isSub, isAction); + clone->deleteLater(); + return true; + } + return false; + }); + return; + } + args.channelPointRewardId = rewardId; + } + + QString content = originalContent; + int messageOffset = stripLeadingReplyMention(tags, content); + + TwitchMessageBuilder builder(chan.get(), message, args, content, isAction); + builder.setMessageOffset(messageOffset); + + if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) + { + const QString replyID = it.value().toString(); + auto threadIt = channel->threads_.find(replyID); + if (threadIt != channel->threads_.end() && !threadIt->second.expired()) + { + // Thread already exists (has a reply) + auto thread = threadIt->second.lock(); + updateReplyParticipatedStatus(tags, message->nick(), builder, + thread, false); + builder.setThread(thread); + } + else + { + // Thread does not yet exist, find root reply and create thread. + auto root = channel->findMessage(replyID); + if (root) + { + // Found root reply message + auto newThread = std::make_shared(root); + updateReplyParticipatedStatus(tags, message->nick(), builder, + newThread, true); + + builder.setThread(newThread); + // Store weak reference to thread in channel + channel->addReplyThread(newThread); + } + } + } + + if (isSub || !builder.isIgnored()) + { + if (isSub) + { + builder->flags.set(MessageFlag::Subscription); + builder->flags.unset(MessageFlag::Highlighted); + } + auto msg = builder.build(); + + IrcMessageHandler::setSimilarityFlags(msg, chan); + + if (!msg->flags.has(MessageFlag::Similar) || + (!getSettings()->hideSimilar && + getSettings()->shownSimilarTriggerHighlights)) + { + builder.triggerHighlights(); + } + + const auto highlighted = msg->flags.has(MessageFlag::Highlighted); + const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions); + + if (highlighted && showInMentions) + { + server.mentionsChannel->addMessage(msg); + } + + chan->addMessage(msg); + if (auto *chatters = dynamic_cast(chan.get())) + { + chatters->addRecentChatter(msg->displayName); + } + } +} + } // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index c07b1a99d..44773b8f3 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -29,59 +29,41 @@ class IrcMessageHandler public: static IrcMessageHandler &instance(); - // parseMessage parses a single IRC message into 0+ Chatterino messages - std::vector parseMessage(Channel *channel, - Communi::IrcMessage *message); - - std::vector parseMessageWithReply( + /** + * Parse an IRC message into 0 or more Chatterino messages + * Takes previously loaded messages into consideration to add reply contexts + **/ + static std::vector parseMessageWithReply( Channel *channel, Communi::IrcMessage *message, std::vector &otherLoaded); - // parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages - std::vector parsePrivMessage( - Channel *channel, Communi::IrcPrivateMessage *message); void handlePrivMessage(Communi::IrcPrivateMessage *message, TwitchIrcServer &server); void handleRoomStateMessage(Communi::IrcMessage *message); - std::optional parseClearChatMessage( - Communi::IrcMessage *message); void handleClearChatMessage(Communi::IrcMessage *message); void handleClearMessageMessage(Communi::IrcMessage *message); void handleUserStateMessage(Communi::IrcMessage *message); void handleGlobalUserStateMessage(Communi::IrcMessage *message); - void handleWhisperMessage(Communi::IrcMessage *message); + void handleWhisperMessage(Communi::IrcMessage *ircMessage); - // parseUserNoticeMessage parses a single IRC USERNOTICE message into 0+ - // Chatterino messages - std::vector parseUserNoticeMessage( - Channel *channel, Communi::IrcMessage *message); void handleUserNoticeMessage(Communi::IrcMessage *message, TwitchIrcServer &server); - void handleModeMessage(Communi::IrcMessage *message); - - // parseNoticeMessage parses a single IRC NOTICE message into 0+ chatterino - // messages - std::vector parseNoticeMessage( - Communi::IrcNoticeMessage *message); void handleNoticeMessage(Communi::IrcNoticeMessage *message); void handleJoinMessage(Communi::IrcMessage *message); void handlePartMessage(Communi::IrcMessage *message); - static float similarity(MessagePtr msg, - const LimitedQueueSnapshot &messages); - static void setSimilarityFlags(MessagePtr message, ChannelPtr channel); - private: - void addMessage(Communi::IrcMessage *message, const QString &target, - const QString &content, TwitchIrcServer &server, - bool isResub, bool isAction); + static float similarity(const MessagePtr &msg, + const LimitedQueueSnapshot &messages); + static void setSimilarityFlags(const MessagePtr &message, + const ChannelPtr &channel); - void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded, - TwitchMessageBuilder &builder); + void addMessage(Communi::IrcMessage *message, const QString &target, + const QString &originalContent, TwitchIrcServer &server, + bool isSub, bool isAction); }; } // namespace chatterino