diff --git a/CHANGELOG.md b/CHANGELOG.md index b150919ed18..54544119b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ - Dev: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5607) - Dev: `GIFTimer` is no longer initialized in tests. (#5608) - Dev: Emojis now use flags instead of a set of strings for capabilities. (#5616) +- Dev: Refactored static `MessageBuilder` helpers to standalone functions. (#5652) ## 2.5.1 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5c71aff9567..4c24ef5724d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -409,6 +409,8 @@ set(SOURCE_FILES providers/twitch/TwitchEmotes.hpp providers/twitch/TwitchHelpers.cpp providers/twitch/TwitchHelpers.hpp + providers/twitch/TwitchIrc.cpp + providers/twitch/TwitchIrc.hpp providers/twitch/TwitchIrcServer.cpp providers/twitch/TwitchIrcServer.hpp providers/twitch/TwitchUser.cpp diff --git a/src/controllers/ignores/IgnoreController.cpp b/src/controllers/ignores/IgnoreController.cpp index 7922b16dd5d..f8e60b6b971 100644 --- a/src/controllers/ignores/IgnoreController.cpp +++ b/src/controllers/ignores/IgnoreController.cpp @@ -1,12 +1,134 @@ #include "controllers/ignores/IgnoreController.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchIrc.hpp" #include "singletons/Settings.hpp" +namespace { + +using namespace chatterino::literals; + +/** + * Computes (only) the replacement of @a match in @a source. + * The parts before and after the match in @a source are ignored. + * + * Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced + * with the string captured by the corresponding capturing group. + * This function should only be used if the regex contains capturing groups. + * + * Since Qt doesn't provide a way of replacing a single match with some replacement + * while supporting both capturing groups and lookahead/-behind in the regex, + * this is included here. It's essentially the implementation of + * QString::replace(const QRegularExpression &, const QString &). + * @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703 + */ +QString makeRegexReplacement(QStringView source, + const QRegularExpression ®ex, + const QRegularExpressionMatch &match, + const QString &replacement) +{ + using SizeType = QString::size_type; + struct QStringCapture { + SizeType pos; + SizeType len; + int captureNumber; + }; + + qsizetype numCaptures = regex.captureCount(); + + // 1. build the backreferences list, holding where the backreferences + // are in the replacement string + QVarLengthArray backReferences; + + SizeType replacementLength = replacement.size(); + for (SizeType i = 0; i < replacementLength - 1; i++) + { + if (replacement[i] != u'\\') + { + continue; + } + + int no = replacement[i + 1].digitValue(); + if (no <= 0 || no > numCaptures) + { + continue; + } + + QStringCapture backReference{.pos = i, .len = 2}; + + if (i < replacementLength - 2) + { + int secondDigit = replacement[i + 2].digitValue(); + if (secondDigit != -1 && ((no * 10) + secondDigit) <= numCaptures) + { + no = (no * 10) + secondDigit; + ++backReference.len; + } + } + + backReference.captureNumber = no; + backReferences.append(backReference); + } + + // 2. iterate on the matches. + // For every match, copy the replacement string in chunks + // with the proper replacements for the backreferences + + // length of the new string, with all the replacements + SizeType newLength = 0; + QVarLengthArray chunks; + QStringView replacementView{replacement}; + + // Initially: empty, as we only care about the replacement + SizeType len = 0; + SizeType lastEnd = 0; + for (const QStringCapture &backReference : std::as_const(backReferences)) + { + // part of "replacement" before the backreference + len = backReference.pos - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // backreference itself + len = match.capturedLength(backReference.captureNumber); + if (len > 0) + { + chunks << source.mid( + match.capturedStart(backReference.captureNumber), len); + newLength += len; + } + + lastEnd = backReference.pos + backReference.len; + } + + // add the last part of the replacement string + len = replacementView.size() - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // 3. assemble the chunks together + QString dst; + dst.reserve(newLength); + for (const QStringView &chunk : std::as_const(chunks)) + { + dst += chunk; + } + return dst; +} + +} // namespace + namespace chatterino { bool isIgnoredMessage(IgnoredMessageParameters &¶ms) @@ -65,4 +187,187 @@ bool isIgnoredMessage(IgnoredMessageParameters &¶ms) return false; } +void processIgnorePhrases(const std::vector &phrases, + QString &content, + std::vector &twitchEmotes) +{ + using SizeType = QString::size_type; + + auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { + // all emotes outside the range come before `it` + // all emotes in the range start at `it` + auto it = std::partition( + twitchEmotes.begin(), twitchEmotes.end(), + [pos, len](const auto &item) { + // returns true for emotes outside the range + return !((item.start >= pos) && item.start < (pos + len)); + }); + std::vector emotesInRange(it, + twitchEmotes.end()); + twitchEmotes.erase(it, twitchEmotes.end()); + return emotesInRange; + }; + + auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { + for (auto &item : twitchEmotes) + { + auto &index = item.start; + if (index >= pos) + { + index += by; + item.end += by; + } + } + }; + + auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, + const auto &midrepl, + SizeType startIndex) { + if (!phrase.containsEmote()) + { + return; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + auto words = midrepl.tokenize(u' '); +#else + auto words = midrepl.split(' '); +#endif + SizeType pos = 0; + for (const auto &word : words) + { + for (const auto &emote : phrase.getEmotes()) + { + if (word == emote.first.string) + { + if (emote.second == nullptr) + { + qCDebug(chatterinoTwitch) + << "emote null" << emote.first.string; + } + twitchEmotes.push_back(TwitchEmoteOccurrence{ + static_cast(startIndex + pos), + static_cast(startIndex + pos + + emote.first.string.length()), + emote.second, + emote.first, + }); + } + } + pos += word.length() + 1; + } + }; + + auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, + SizeType length, const QString &replacement) { + auto removedEmotes = removeEmotesInRange(from, length); + content.replace(from, length, replacement); + auto wordStart = from; + while (wordStart > 0) + { + if (content[wordStart - 1] == ' ') + { + break; + } + --wordStart; + } + auto wordEnd = from + replacement.length(); + while (wordEnd < content.length()) + { + if (content[wordEnd] == ' ') + { + break; + } + ++wordEnd; + } + + shiftIndicesAfter(static_cast(from + length), + static_cast(replacement.length() - length)); + + auto midExtendedRef = + QStringView{content}.mid(wordStart, wordEnd - wordStart); + + for (auto &emote : removedEmotes) + { + if (emote.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "Invalid emote occurrence" << emote.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + emote.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + auto match = emoteregex.matchView(midExtendedRef); +#else + auto match = emoteregex.match(midExtendedRef); +#endif + if (match.hasMatch()) + { + emote.start = static_cast(from + match.capturedStart()); + emote.end = static_cast(from + match.capturedEnd()); + twitchEmotes.push_back(std::move(emote)); + } + } + + addReplEmotes(phrase, midExtendedRef, wordStart); + }; + + for (const auto &phrase : phrases) + { + if (phrase.isBlock()) + { + continue; + } + const auto &pattern = phrase.getPattern(); + if (pattern.isEmpty()) + { + continue; + } + if (phrase.isRegex()) + { + const auto ®ex = phrase.getRegex(); + if (!regex.isValid()) + { + continue; + } + + QRegularExpressionMatch match; + size_t iterations = 0; + SizeType from = 0; + while ((from = content.indexOf(regex, from, &match)) != -1) + { + auto replacement = phrase.getReplace(); + if (regex.captureCount() > 0) + { + replacement = makeRegexReplacement(content, regex, match, + replacement); + } + + replaceMessageAt(phrase, from, match.capturedLength(), + replacement); + from += phrase.getReplace().length(); + iterations++; + if (iterations >= 128) + { + content = u"Too many replacements - check your ignores!"_s; + return; + } + } + + continue; + } + + SizeType from = 0; + while ((from = content.indexOf(pattern, from, + phrase.caseSensitivity())) != -1) + { + replaceMessageAt(phrase, from, pattern.length(), + phrase.getReplace()); + from += phrase.getReplace().length(); + } + } +} + } // namespace chatterino diff --git a/src/controllers/ignores/IgnoreController.hpp b/src/controllers/ignores/IgnoreController.hpp index 4c204862149..9555315374a 100644 --- a/src/controllers/ignores/IgnoreController.hpp +++ b/src/controllers/ignores/IgnoreController.hpp @@ -2,8 +2,13 @@ #include +#include + namespace chatterino { +class IgnorePhrase; +struct TwitchEmoteOccurrence; + enum class ShowIgnoredUsersMessages { Never, IfModerator, IfBroadcaster }; struct IgnoredMessageParameters { @@ -16,4 +21,17 @@ struct IgnoredMessageParameters { bool isIgnoredMessage(IgnoredMessageParameters &¶ms); +/// @brief Processes replacement ignore-phrases for a message +/// +/// @param phrases A list of IgnorePhrases to process. Block phrases as well as +/// invalid phrases are ignored. +/// @param content The message text. This gets altered by replacements. +/// @param twitchEmotes A list of emotes present in the message. Occurrences +/// that have been removed from the message will also be +/// removed in this list. Similarly, if new emotes are added +/// from a replacement, this list gets updated as well. +void processIgnorePhrases(const std::vector &phrases, + QString &content, + std::vector &twitchEmotes); + } // namespace chatterino diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 3802497b039..e470b01cec3 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -30,6 +30,7 @@ #include "providers/twitch/TwitchBadge.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrc.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" #include "singletons/Resources.hpp" @@ -237,77 +238,6 @@ QString stylizeUsername(const QString &username, const Message &message) return usernameText; } -void appendTwitchEmoteOccurrences(const QString &emote, - std::vector &vec, - const std::vector &correctPositions, - const QString &originalMessage, - int messageOffset) -{ - auto *app = getApp(); - if (!emote.contains(':')) - { - return; - } - - auto parameters = emote.split(':'); - - if (parameters.length() < 2) - { - return; - } - - auto id = EmoteId{parameters.at(0)}; - - auto occurrences = parameters.at(1).split(','); - - for (const QString &occurrence : occurrences) - { - auto coords = occurrence.split('-'); - - if (coords.length() < 2) - { - return; - } - - auto from = coords.at(0).toUInt() - messageOffset; - auto to = coords.at(1).toUInt() - messageOffset; - auto maxPositions = correctPositions.size(); - if (from > to || to >= maxPositions) - { - // Emote coords are out of range - qCDebug(chatterinoTwitch) - << "Emote coords" << from << "-" << to << "are out of range (" - << maxPositions << ")"; - return; - } - - auto start = correctPositions[from]; - auto end = correctPositions[to]; - if (start > end || start < 0 || end > originalMessage.length()) - { - // Emote coords are out of range from the modified character positions - qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to - << "are out of range after offsets (" - << originalMessage.length() << ")"; - return; - } - - auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; - TwitchEmoteOccurrence emoteOccurrence{ - start, - end, - app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), - name, - }; - if (emoteOccurrence.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "nullptr" << emoteOccurrence.name.string; - } - vec.push_back(std::move(emoteOccurrence)); - } -} - std::optional getTwitchBadge(const Badge &badge, const TwitchChannel *twitchChannel) { @@ -420,120 +350,6 @@ void appendBadges(MessageBuilder *builder, const std::vector &badges, builder->message().badgeInfos = badgeInfos; } -/** - * Computes (only) the replacement of @a match in @a source. - * The parts before and after the match in @a source are ignored. - * - * Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced - * with the string captured by the corresponding capturing group. - * This function should only be used if the regex contains capturing groups. - * - * Since Qt doesn't provide a way of replacing a single match with some replacement - * while supporting both capturing groups and lookahead/-behind in the regex, - * this is included here. It's essentially the implementation of - * QString::replace(const QRegularExpression &, const QString &). - * @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703 - */ -QString makeRegexReplacement(QStringView source, - const QRegularExpression ®ex, - const QRegularExpressionMatch &match, - const QString &replacement) -{ - using SizeType = QString::size_type; - struct QStringCapture { - SizeType pos; - SizeType len; - int captureNumber; - }; - - qsizetype numCaptures = regex.captureCount(); - - // 1. build the backreferences list, holding where the backreferences - // are in the replacement string - QVarLengthArray backReferences; - - SizeType replacementLength = replacement.size(); - for (SizeType i = 0; i < replacementLength - 1; i++) - { - if (replacement[i] != u'\\') - { - continue; - } - - int no = replacement[i + 1].digitValue(); - if (no <= 0 || no > numCaptures) - { - continue; - } - - QStringCapture backReference{.pos = i, .len = 2}; - - if (i < replacementLength - 2) - { - int secondDigit = replacement[i + 2].digitValue(); - if (secondDigit != -1 && ((no * 10) + secondDigit) <= numCaptures) - { - no = (no * 10) + secondDigit; - ++backReference.len; - } - } - - backReference.captureNumber = no; - backReferences.append(backReference); - } - - // 2. iterate on the matches. - // For every match, copy the replacement string in chunks - // with the proper replacements for the backreferences - - // length of the new string, with all the replacements - SizeType newLength = 0; - QVarLengthArray chunks; - QStringView replacementView{replacement}; - - // Initially: empty, as we only care about the replacement - SizeType len = 0; - SizeType lastEnd = 0; - for (const QStringCapture &backReference : std::as_const(backReferences)) - { - // part of "replacement" before the backreference - len = backReference.pos - lastEnd; - if (len > 0) - { - chunks << replacementView.mid(lastEnd, len); - newLength += len; - } - - // backreference itself - len = match.capturedLength(backReference.captureNumber); - if (len > 0) - { - chunks << source.mid( - match.capturedStart(backReference.captureNumber), len); - newLength += len; - } - - lastEnd = backReference.pos + backReference.len; - } - - // add the last part of the replacement string - len = replacementView.size() - lastEnd; - if (len > 0) - { - chunks << replacementView.mid(lastEnd, len); - newLength += len; - } - - // 3. assemble the chunks together - QString dst; - dst.reserve(newLength); - for (const QStringView &chunk : std::as_const(chunks)) - { - dst += chunk; - } - return dst; -} - bool doesWordContainATwitchEmote( int cursor, const QString &word, const std::vector &twitchEmotes, @@ -1358,13 +1174,12 @@ MessagePtr MessageBuilder::build() } // Twitch emotes - auto twitchEmotes = MessageBuilder::parseTwitchEmotes( - this->tags, this->originalMessage_, this->messageOffset_); + auto twitchEmotes = parseTwitchEmotes(this->tags, this->originalMessage_, + this->messageOffset_); // This runs through all ignored phrases and runs its replacements on this->originalMessage_ - MessageBuilder::processIgnorePhrases( - *getSettings()->ignoredMessages.readOnly(), this->originalMessage_, - twitchEmotes); + processIgnorePhrases(*getSettings()->ignoredMessages.readOnly(), + this->originalMessage_, twitchEmotes); std::sort(twitchEmotes.begin(), twitchEmotes.end(), [](const auto &a, const auto &b) { @@ -2178,268 +1993,6 @@ MessagePtr MessageBuilder::makeLowTrustUpdateMessage( return builder.release(); } -std::unordered_map MessageBuilder::parseBadgeInfoTag( - const QVariantMap &tags) -{ - std::unordered_map infoMap; - - auto infoIt = tags.constFind("badge-info"); - if (infoIt == tags.end()) - { - return infoMap; - } - - auto info = infoIt.value().toString().split(',', Qt::SkipEmptyParts); - - for (const QString &badge : info) - { - infoMap.emplace(slashKeyValue(badge)); - } - - return infoMap; -} - -std::vector MessageBuilder::parseBadgeTag(const QVariantMap &tags) -{ - std::vector b; - - auto badgesIt = tags.constFind("badges"); - if (badgesIt == tags.end()) - { - return b; - } - - auto badges = badgesIt.value().toString().split(',', Qt::SkipEmptyParts); - - for (const QString &badge : badges) - { - if (!badge.contains('/')) - { - continue; - } - - auto pair = slashKeyValue(badge); - b.emplace_back(Badge{pair.first, pair.second}); - } - - return b; -} - -std::vector MessageBuilder::parseTwitchEmotes( - const QVariantMap &tags, const QString &originalMessage, int messageOffset) -{ - // Twitch emotes - std::vector twitchEmotes; - - auto emotesTag = tags.find("emotes"); - - if (emotesTag == tags.end()) - { - return twitchEmotes; - } - - QStringList emoteString = emotesTag.value().toString().split('/'); - std::vector correctPositions; - for (int i = 0; i < originalMessage.size(); ++i) - { - if (!originalMessage.at(i).isLowSurrogate()) - { - correctPositions.push_back(i); - } - } - for (const QString &emote : emoteString) - { - appendTwitchEmoteOccurrences(emote, twitchEmotes, correctPositions, - originalMessage, messageOffset); - } - - return twitchEmotes; -} - -void MessageBuilder::processIgnorePhrases( - const std::vector &phrases, QString &originalMessage, - std::vector &twitchEmotes) -{ - using SizeType = QString::size_type; - - auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { - // all emotes outside the range come before `it` - // all emotes in the range start at `it` - auto it = std::partition( - twitchEmotes.begin(), twitchEmotes.end(), - [pos, len](const auto &item) { - // returns true for emotes outside the range - return !((item.start >= pos) && item.start < (pos + len)); - }); - std::vector emotesInRange(it, - twitchEmotes.end()); - twitchEmotes.erase(it, twitchEmotes.end()); - return emotesInRange; - }; - - auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { - for (auto &item : twitchEmotes) - { - auto &index = item.start; - if (index >= pos) - { - index += by; - item.end += by; - } - } - }; - - auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, - const auto &midrepl, - SizeType startIndex) { - if (!phrase.containsEmote()) - { - return; - } - -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - auto words = midrepl.tokenize(u' '); -#else - auto words = midrepl.split(' '); -#endif - SizeType pos = 0; - for (const auto &word : words) - { - for (const auto &emote : phrase.getEmotes()) - { - if (word == emote.first.string) - { - if (emote.second == nullptr) - { - qCDebug(chatterinoTwitch) - << "emote null" << emote.first.string; - } - twitchEmotes.push_back(TwitchEmoteOccurrence{ - static_cast(startIndex + pos), - static_cast(startIndex + pos + - emote.first.string.length()), - emote.second, - emote.first, - }); - } - } - pos += word.length() + 1; - } - }; - - auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, - SizeType length, const QString &replacement) { - auto removedEmotes = removeEmotesInRange(from, length); - originalMessage.replace(from, length, replacement); - auto wordStart = from; - while (wordStart > 0) - { - if (originalMessage[wordStart - 1] == ' ') - { - break; - } - --wordStart; - } - auto wordEnd = from + replacement.length(); - while (wordEnd < originalMessage.length()) - { - if (originalMessage[wordEnd] == ' ') - { - break; - } - ++wordEnd; - } - - shiftIndicesAfter(static_cast(from + length), - static_cast(replacement.length() - length)); - - auto midExtendedRef = - QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart); - - for (auto &emote : removedEmotes) - { - if (emote.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "Invalid emote occurrence" << emote.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + emote.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) - auto match = emoteregex.matchView(midExtendedRef); -#else - auto match = emoteregex.match(midExtendedRef); -#endif - if (match.hasMatch()) - { - emote.start = static_cast(from + match.capturedStart()); - emote.end = static_cast(from + match.capturedEnd()); - twitchEmotes.push_back(std::move(emote)); - } - } - - addReplEmotes(phrase, midExtendedRef, wordStart); - }; - - for (const auto &phrase : phrases) - { - if (phrase.isBlock()) - { - continue; - } - const auto &pattern = phrase.getPattern(); - if (pattern.isEmpty()) - { - continue; - } - if (phrase.isRegex()) - { - const auto ®ex = phrase.getRegex(); - if (!regex.isValid()) - { - continue; - } - - QRegularExpressionMatch match; - size_t iterations = 0; - SizeType from = 0; - while ((from = originalMessage.indexOf(regex, from, &match)) != -1) - { - auto replacement = phrase.getReplace(); - if (regex.captureCount() > 0) - { - replacement = makeRegexReplacement(originalMessage, regex, - match, replacement); - } - - replaceMessageAt(phrase, from, match.capturedLength(), - replacement); - from += phrase.getReplace().length(); - iterations++; - if (iterations >= 128) - { - originalMessage = - u"Too many replacements - check your ignores!"_s; - return; - } - } - - continue; - } - - SizeType from = 0; - while ((from = originalMessage.indexOf(pattern, from, - phrase.caseSensitivity())) != -1) - { - replaceMessageAt(phrase, from, pattern.length(), - phrase.getReplace()); - from += phrase.getReplace().length(); - } - } -} - void MessageBuilder::addTextOrEmoji(EmotePtr emote) { this->emplace(emote, MessageElementFlag::EmojiAll); @@ -3159,7 +2712,7 @@ void MessageBuilder::appendTwitchBadges() return; } - auto badgeInfos = MessageBuilder::parseBadgeInfoTag(this->tags); + auto badgeInfos = parseBadgeInfoTag(this->tags); auto badges = parseBadgeTag(this->tags); appendBadges(this, badges, badgeInfos, this->twitchChannel); } diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 651de404513..aa2933b640e 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -45,6 +45,7 @@ struct HelixVip; using HelixModerator = HelixVip; struct ChannelPointReward; struct DeleteAction; +struct TwitchEmoteOccurrence; namespace linkparser { struct Parsed; @@ -89,19 +90,6 @@ struct MessageParseArgs { QString channelPointRewardId = ""; }; -struct TwitchEmoteOccurrence { - int start; - int end; - EmotePtr ptr; - EmoteName name; - - bool operator==(const TwitchEmoteOccurrence &other) const - { - return std::tie(this->start, this->end, this->ptr, this->name) == - std::tie(other.start, other.end, other.ptr, other.name); - } -}; - class MessageBuilder { public: @@ -237,20 +225,6 @@ class MessageBuilder static MessagePtr makeLowTrustUpdateMessage( const PubSubLowTrustUsersMessage &action); - static std::unordered_map parseBadgeInfoTag( - const QVariantMap &tags); - - // Parses "badges" tag which contains a comma separated list of key-value elements - static std::vector parseBadgeTag(const QVariantMap &tags); - - static std::vector parseTwitchEmotes( - const QVariantMap &tags, const QString &originalMessage, - int messageOffset); - - static void processIgnorePhrases( - const std::vector &phrases, QString &originalMessage, - std::vector &twitchEmotes); - protected: void addTextOrEmoji(EmotePtr emote); void addTextOrEmoji(const QString &string_); diff --git a/src/providers/twitch/TwitchIrc.cpp b/src/providers/twitch/TwitchIrc.cpp new file mode 100644 index 00000000000..962618f6bb7 --- /dev/null +++ b/src/providers/twitch/TwitchIrc.cpp @@ -0,0 +1,166 @@ +#include "providers/twitch/TwitchIrc.hpp" + +#include "Application.hpp" +#include "common/Aliases.hpp" +#include "common/QLogging.hpp" +#include "singletons/Emotes.hpp" +#include "util/IrcHelpers.hpp" + +namespace { + +using namespace chatterino; + +void appendTwitchEmoteOccurrences(const QString &emote, + std::vector &vec, + const std::vector &correctPositions, + const QString &originalMessage, + int messageOffset) +{ + auto *app = getApp(); + if (!emote.contains(':')) + { + return; + } + + auto parameters = emote.split(':'); + + if (parameters.length() < 2) + { + return; + } + + auto id = EmoteId{parameters.at(0)}; + + auto occurrences = parameters.at(1).split(','); + + for (const QString &occurrence : occurrences) + { + auto coords = occurrence.split('-'); + + if (coords.length() < 2) + { + return; + } + + auto from = coords.at(0).toUInt() - messageOffset; + auto to = coords.at(1).toUInt() - messageOffset; + auto maxPositions = correctPositions.size(); + if (from > to || to >= maxPositions) + { + // Emote coords are out of range + qCDebug(chatterinoTwitch) + << "Emote coords" << from << "-" << to << "are out of range (" + << maxPositions << ")"; + return; + } + + auto start = correctPositions[from]; + auto end = correctPositions[to]; + if (start > end || start < 0 || end > originalMessage.length()) + { + // Emote coords are out of range from the modified character positions + qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to + << "are out of range after offsets (" + << originalMessage.length() << ")"; + return; + } + + auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; + TwitchEmoteOccurrence emoteOccurrence{ + start, + end, + app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), + name, + }; + if (emoteOccurrence.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "nullptr" << emoteOccurrence.name.string; + } + vec.push_back(std::move(emoteOccurrence)); + } +} + +} // namespace + +namespace chatterino { + +std::unordered_map parseBadgeInfoTag(const QVariantMap &tags) +{ + std::unordered_map infoMap; + + auto infoIt = tags.constFind("badge-info"); + if (infoIt == tags.end()) + { + return infoMap; + } + + auto info = infoIt.value().toString().split(',', Qt::SkipEmptyParts); + + for (const QString &badge : info) + { + infoMap.emplace(slashKeyValue(badge)); + } + + return infoMap; +} + +std::vector parseBadgeTag(const QVariantMap &tags) +{ + std::vector b; + + auto badgesIt = tags.constFind("badges"); + if (badgesIt == tags.end()) + { + return b; + } + + auto badges = badgesIt.value().toString().split(',', Qt::SkipEmptyParts); + + for (const QString &badge : badges) + { + if (!badge.contains('/')) + { + continue; + } + + auto pair = slashKeyValue(badge); + b.emplace_back(Badge{pair.first, pair.second}); + } + + return b; +} + +std::vector parseTwitchEmotes(const QVariantMap &tags, + const QString &content, + int messageOffset) +{ + // Twitch emotes + std::vector twitchEmotes; + + auto emotesTag = tags.find("emotes"); + + if (emotesTag == tags.end()) + { + return twitchEmotes; + } + + QStringList emoteString = emotesTag.value().toString().split('/'); + std::vector correctPositions; + for (int i = 0; i < content.size(); ++i) + { + if (!content.at(i).isLowSurrogate()) + { + correctPositions.push_back(i); + } + } + for (const QString &emote : emoteString) + { + appendTwitchEmoteOccurrences(emote, twitchEmotes, correctPositions, + content, messageOffset); + } + + return twitchEmotes; +} + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchIrc.hpp b/src/providers/twitch/TwitchIrc.hpp new file mode 100644 index 00000000000..60529fb7776 --- /dev/null +++ b/src/providers/twitch/TwitchIrc.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "messages/Emote.hpp" +#include "providers/twitch/TwitchBadge.hpp" + +#include +#include + +#include + +namespace chatterino { + +struct TwitchEmoteOccurrence { + int start; + int end; + EmotePtr ptr; + EmoteName name; + + bool operator==(const TwitchEmoteOccurrence &other) const + { + return std::tie(this->start, this->end, this->ptr, this->name) == + std::tie(other.start, other.end, other.ptr, other.name); + } +}; + +/// @brief Parses the `badge-info` tag of an IRC message +/// +/// The `badge-info` tag maps badge-names to a value. Subscriber badges, for +/// example, are mapped to the number of months the chatter is subscribed for. +/// +/// **Example**: +/// `badge-info=subscriber/22` would be parsed as `{ subscriber => 22 }` +/// +/// @param tags The tags of the IRC message +/// @returns A map of badge-names to their values +std::unordered_map parseBadgeInfoTag(const QVariantMap &tags); + +/// @brief Parses the `badges` tag of an IRC message +/// +/// The `badges` tag contains a comma separated list of key-value elements which +/// make up the name and version of each badge. +/// +/// **Example**: +/// `badges=broadcaster/1,subscriber/18` would be parsed as +/// `[(broadcaster, 1), (subscriber, 18)]` +/// +/// @param tags The tags of the IRC message +/// @returns A list of badges (name and version) +std::vector parseBadgeTag(const QVariantMap &tags); + +/// @brief Parses Twitch emotes in an IRC message +/// +/// @param tags The tags of the IRC message +/// @param content The message text. This might be shortened due to skipping +/// content at the start. `messageOffset` describes this offset. +/// @param messageOffset The offset of `content` compared to the original +/// message text. Used for calculating indices into the +/// message. An offset of 3, for example, indicates that +/// `content` excludes the first three characters of the +/// original message (`@a foo` (original message) -> `foo` +/// (content)). +/// @returns A list of emotes and their positions +std::vector parseTwitchEmotes(const QVariantMap &tags, + const QString &content, + int messageOffset); + +} // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 36e7e31d8ad..8a6647fee9e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -48,6 +48,8 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp ${CMAKE_CURRENT_LIST_DIR}/src/CancellationToken.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/TwitchIrc.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/IgnoreController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp # Add your new file above this line! diff --git a/tests/src/IgnoreController.cpp b/tests/src/IgnoreController.cpp new file mode 100644 index 00000000000..f3e061f5129 --- /dev/null +++ b/tests/src/IgnoreController.cpp @@ -0,0 +1,186 @@ +#include "controllers/ignores/IgnoreController.hpp" + +#include "controllers/accounts/AccountController.hpp" +#include "mocks/BaseApplication.hpp" +#include "mocks/Emotes.hpp" +#include "providers/twitch/TwitchIrc.hpp" +#include "Test.hpp" + +using namespace chatterino; + +namespace { + +class MockApplication : public mock::BaseApplication +{ +public: + MockApplication() = default; + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + AccountController *getAccounts() override + { + return &this->accounts; + } + + mock::Emotes emotes; + AccountController accounts; +}; + +} // namespace + +class TestIgnoreController : public ::testing::Test +{ +protected: + void SetUp() override + { + this->mockApplication = std::make_unique(); + } + + void TearDown() override + { + this->mockApplication.reset(); + } + + std::unique_ptr mockApplication; +}; + +TEST_F(TestIgnoreController, processIgnorePhrases) +{ + struct TestCase { + std::vector phrases; + QString input; + std::vector twitchEmotes; + QString expectedMessage; + std::vector expectedTwitchEmotes; + }; + + auto *twitchEmotes = this->mockApplication->getEmotes()->getTwitchEmotes(); + + auto emoteAt = [&](int at, const QString &name) { + return TwitchEmoteOccurrence{ + .start = at, + .end = static_cast(at + name.size() - 1), + .ptr = + twitchEmotes->getOrCreateEmote(EmoteId{name}, EmoteName{name}), + .name = EmoteName{name}, + }; + }; + + auto regularReplace = [](auto pattern, auto replace, + bool caseSensitive = true) { + return IgnorePhrase(pattern, false, false, replace, caseSensitive); + }; + auto regexReplace = [](auto pattern, auto regex, + bool caseSensitive = true) { + return IgnorePhrase(pattern, true, false, regex, caseSensitive); + }; + + std::vector testCases{ + { + {regularReplace("foo1", "baz1")}, + "foo1 Kappa", + {emoteAt(4, "Kappa")}, + "baz1 Kappa", + {emoteAt(4, "Kappa")}, + }, + { + {regularReplace("foo1", "baz1", false)}, + "FoO1 Kappa", + {emoteAt(4, "Kappa")}, + "baz1 Kappa", + {emoteAt(4, "Kappa")}, + }, + { + {regexReplace("f(o+)1", "baz1[\\1]")}, + "foo1 Kappa", + {emoteAt(4, "Kappa")}, + "baz1[oo] Kappa", + {emoteAt(8, "Kappa")}, + }, + + { + {regexReplace("f(o+)1", R"(baz1[\0][\1][\2])")}, + "foo1 Kappa", + {emoteAt(4, "Kappa")}, + "baz1[\\0][oo][\\2] Kappa", + {emoteAt(16, "Kappa")}, + }, + { + {regexReplace("f(o+)(\\d+)", "baz1[\\1+\\2]")}, + "foo123 Kappa", + {emoteAt(6, "Kappa")}, + "baz1[oo+123] Kappa", + {emoteAt(12, "Kappa")}, + }, + { + {regexReplace("(?<=foo)(\\d+)", "[\\1]")}, + "foo123 Kappa", + {emoteAt(6, "Kappa")}, + "foo[123] Kappa", + {emoteAt(8, "Kappa")}, + }, + { + {regexReplace("a(?=a| )", "b")}, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + "Kappa", + {emoteAt(127, "Kappa")}, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "bbbb" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb " + "Kappa", + {emoteAt(127, "Kappa")}, + }, + { + {regexReplace("abc", "def", false)}, + "AbC Kappa", + {emoteAt(3, "Kappa")}, + "def Kappa", + {emoteAt(3, "Kappa")}, + }, + { + { + regexReplace("abc", "def", false), + regularReplace("def", "ghi"), + }, + "AbC Kappa", + {emoteAt(3, "Kappa")}, + "ghi Kappa", + {emoteAt(3, "Kappa")}, + }, + { + { + regexReplace("a(?=a| )", "b"), + regexReplace("b(?=b| )", "c"), + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + "Kappa", + {emoteAt(127, "Kappa")}, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc " + "Kappa", + {emoteAt(127, "Kappa")}, + }, + }; + + for (const auto &test : testCases) + { + auto message = test.input; + auto emotes = test.twitchEmotes; + processIgnorePhrases(test.phrases, message, emotes); + + EXPECT_EQ(message, test.expectedMessage) + << "Message not equal for input '" << test.input + << "' - expected: '" << test.expectedMessage << "' got: '" + << message << "'"; + EXPECT_EQ(emotes, test.expectedTwitchEmotes) + << "Twitch emotes not equal for input '" << test.input + << "' and output '" << message << "'"; + } +} diff --git a/tests/src/MessageBuilder.cpp b/tests/src/MessageBuilder.cpp index b76d78018e0..107d4023c18 100644 --- a/tests/src/MessageBuilder.cpp +++ b/tests/src/MessageBuilder.cpp @@ -447,454 +447,6 @@ QT_WARNING_POP } // namespace -TEST(MessageBuilder, CommaSeparatedListTagParsing) -{ - struct TestCase { - QString input; - std::pair expectedOutput; - }; - - std::vector testCases{ - { - "broadcaster/1", - {"broadcaster", "1"}, - }, - { - "predictions/foo/bar/baz", - {"predictions", "foo/bar/baz"}, - }, - { - "test/", - {"test", ""}, - }, - { - "/", - {"", ""}, - }, - { - "/value", - {"", "value"}, - }, - { - "", - {"", ""}, - }, - }; - - for (const auto &test : testCases) - { - auto output = slashKeyValue(test.input); - - EXPECT_EQ(output, test.expectedOutput) - << "Input " << test.input << " failed"; - } -} - -class TestMessageBuilder : public ::testing::Test -{ -protected: - void SetUp() override - { - this->mockApplication = std::make_unique(); - } - - void TearDown() override - { - this->mockApplication.reset(); - } - - std::unique_ptr mockApplication; -}; - -TEST(MessageBuilder, BadgeInfoParsing) -{ - struct TestCase { - QByteArray input; - std::unordered_map expectedBadgeInfo; - std::vector expectedBadges; - }; - - std::vector testCases{ - { - R"(@badge-info=predictions/<<<<<<\sHEAD[15A⸝asdf/test;badges=predictions/pink-2;client-nonce=9dbb88e516edf4efb055c011f91ea0cf;color=#FF4500;display-name=もっと頑張って;emotes=;first-msg=0;flags=;id=feb00b12-4ec5-4f77-9160-667de463dab1;mod=0;room-id=99631238;subscriber=0;tmi-sent-ts=1653494874297;turbo=0;user-id=648946956;user-type= :zniksbot!zniksbot@zniksbot.tmi.twitch.tv PRIVMSG #zneix :-tags")", - { - {"predictions", R"(<<<<<<\sHEAD[15A⸝asdf/test)"}, - }, - { - Badge{"predictions", "pink-2"}, - }, - }, - { - R"(@badge-info=predictions/<<<<<<\sHEAD[15A⸝asdf/test,founder/17;badges=predictions/pink-2,vip/1,founder/0,bits/1;client-nonce=9b836e232170a9df213aefdcb458b67e;color=#696969;display-name=NotKarar;emotes=;first-msg=0;flags=;id=e00881bd-5f21-4993-8bbd-1736cd13d42e;mod=0;room-id=99631238;subscriber=1;tmi-sent-ts=1653494879409;turbo=0;user-id=89954186;user-type= :notkarar!notkarar@notkarar.tmi.twitch.tv PRIVMSG #zneix :-tags)", - { - {"predictions", R"(<<<<<<\sHEAD[15A⸝asdf/test)"}, - {"founder", "17"}, - }, - { - Badge{"predictions", "pink-2"}, - Badge{"vip", "1"}, - Badge{"founder", "0"}, - Badge{"bits", "1"}, - }, - }, - { - R"(@badge-info=predictions/foo/bar/baz;badges=predictions/blue-1,moderator/1,glhf-pledge/1;client-nonce=f73f16228e6e32f8e92b47ab8283b7e1;color=#1E90FF;display-name=zneixbot;emotes=30259:6-12;first-msg=0;flags=;id=9682a5f1-a0b0-45e2-be9f-8074b58c5f8f;mod=1;room-id=99631238;subscriber=0;tmi-sent-ts=1653573594035;turbo=0;user-id=463521670;user-type=mod :zneixbot!zneixbot@zneixbot.tmi.twitch.tv PRIVMSG #zneix :-tags HeyGuys)", - { - {"predictions", "foo/bar/baz"}, - }, - { - Badge{"predictions", "blue-1"}, - Badge{"moderator", "1"}, - Badge{"glhf-pledge", "1"}, - }, - }, - { - R"(@badge-info=subscriber/22;badges=broadcaster/1,subscriber/18,glhf-pledge/1;color=#F97304;display-name=zneix;emotes=;first-msg=0;flags=;id=1d99f67f-a566-4416-a4e2-e85d7fce9223;mod=0;room-id=99631238;subscriber=1;tmi-sent-ts=1653612232758;turbo=0;user-id=99631238;user-type= :zneix!zneix@zneix.tmi.twitch.tv PRIVMSG #zneix :-tags)", - { - {"subscriber", "22"}, - }, - { - Badge{"broadcaster", "1"}, - Badge{"subscriber", "18"}, - Badge{"glhf-pledge", "1"}, - }, - }, - }; - - for (const auto &test : testCases) - { - auto *privmsg = - Communi::IrcPrivateMessage::fromData(test.input, nullptr); - - auto outputBadgeInfo = - MessageBuilder::parseBadgeInfoTag(privmsg->tags()); - EXPECT_EQ(outputBadgeInfo, test.expectedBadgeInfo) - << "Input for badgeInfo " << test.input << " failed"; - - auto outputBadges = MessageBuilder::parseBadgeTag(privmsg->tags()); - EXPECT_EQ(outputBadges, test.expectedBadges) - << "Input for badges " << test.input << " failed"; - - delete privmsg; - } -} - -TEST_F(TestMessageBuilder, ParseTwitchEmotes) -{ - struct TestCase { - QByteArray input; - std::vector expectedTwitchEmotes; - }; - - auto *twitchEmotes = this->mockApplication->getEmotes()->getTwitchEmotes(); - - std::vector testCases{ - { - // action /me message - R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=90ef1e46-8baa-4bf2-9c54-272f39d6fa11;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662206235860;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :ACTION Kappa)", - { - {{ - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote(EmoteId{"25"}, - EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }}, - }, - }, - { - R"(@badge-info=subscriber/17;badges=subscriber/12,no_audio/1;color=#EBA2C0;display-name=jammehcow;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=9c2dd916-5a6d-4c1f-9fe7-a081b62a9c6b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662201093248;turbo=0;user-id=82674227;user-type= :jammehcow!jammehcow@jammehcow.tmi.twitch.tv PRIVMSG #pajlada :Kappa)", - { - {{ - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote(EmoteId{"25"}, - EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }}, - }, - }, - { - R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=1902:0-4;first-msg=0;flags=;id=9b1c3cb9-7817-47ea-add1-f9d4a9b4f846;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201095690;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Keepo)", - { - {{ - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote(EmoteId{"1902"}, - EmoteName{"Keepo"}), // ptr - EmoteName{"Keepo"}, // name - }}, - }, - }, - { - R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=25:0-4/1902:6-10/305954156:12-19;first-msg=0;flags=;id=7be87072-bf24-4fa3-b3df-0ea6fa5f1474;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201102276;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Kappa Keepo PogChamp)", - { - { - { - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"25"}, EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }, - { - 6, // start - 10, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"1902"}, EmoteName{"Keepo"}), // ptr - EmoteName{"Keepo"}, // name - }, - { - 12, // start - 19, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"305954156"}, - EmoteName{"PogChamp"}), // ptr - EmoteName{"PogChamp"}, // name - }, - }, - }, - }, - { - R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4,6-10;first-msg=0;flags=;id=f7516287-e5d1-43ca-974e-fe0cff84400b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204375009;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa Kappa)", - { - { - { - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"25"}, EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }, - { - 6, // start - 10, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"25"}, EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }, - }, - }, - }, - { - R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:0-4,8-12;first-msg=0;flags=;id=44f85d39-b5fb-475d-8555-f4244f2f7e82;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204423418;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa 😂 Kappa)", - { - { - { - 0, // start - 4, // end - twitchEmotes->getOrCreateEmote( - EmoteId{"25"}, EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }, - { - 9, // start - modified due to emoji - 13, // end - modified due to emoji - twitchEmotes->getOrCreateEmote( - EmoteId{"25"}, EmoteName{"Kappa"}), // ptr - EmoteName{"Kappa"}, // name - }, - }, - }, - }, - { - // start out of range - R"(@emotes=84608:9-10 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", - {}, - }, - { - // one character emote - R"(@emotes=84608:0-0 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", - { - { - 0, // start - 0, // end - twitchEmotes->getOrCreateEmote(EmoteId{"84608"}, - EmoteName{"f"}), // ptr - EmoteName{"f"}, // name - }, - }, - }, - { - // two character emote - R"(@emotes=84609:0-1 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", - { - { - 0, // start - 1, // end - twitchEmotes->getOrCreateEmote(EmoteId{"84609"}, - EmoteName{"fo"}), // ptr - EmoteName{"fo"}, // name - }, - }, - }, - { - // end out of range - R"(@emotes=84608:0-15 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", - {}, - }, - { - // range bad (end character before start) - R"(@emotes=84608:15-2 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", - {}, - }, - }; - - for (const auto &test : testCases) - { - auto *privmsg = dynamic_cast( - Communi::IrcPrivateMessage::fromData(test.input, nullptr)); - QString originalMessage = privmsg->content(); - - // TODO: Add tests with replies - auto actualTwitchEmotes = MessageBuilder::parseTwitchEmotes( - privmsg->tags(), originalMessage, 0); - - EXPECT_EQ(actualTwitchEmotes, test.expectedTwitchEmotes) - << "Input for twitch emotes " << test.input << " failed"; - - delete privmsg; - } -} - -TEST_F(TestMessageBuilder, IgnoresReplace) -{ - struct TestCase { - std::vector phrases; - QString input; - std::vector twitchEmotes; - QString expectedMessage; - std::vector expectedTwitchEmotes; - }; - - auto *twitchEmotes = this->mockApplication->getEmotes()->getTwitchEmotes(); - - auto emoteAt = [&](int at, const QString &name) { - return TwitchEmoteOccurrence{ - .start = at, - .end = static_cast(at + name.size() - 1), - .ptr = - twitchEmotes->getOrCreateEmote(EmoteId{name}, EmoteName{name}), - .name = EmoteName{name}, - }; - }; - - auto regularReplace = [](auto pattern, auto replace, - bool caseSensitive = true) { - return IgnorePhrase(pattern, false, false, replace, caseSensitive); - }; - auto regexReplace = [](auto pattern, auto regex, - bool caseSensitive = true) { - return IgnorePhrase(pattern, true, false, regex, caseSensitive); - }; - - std::vector testCases{ - { - {regularReplace("foo1", "baz1")}, - "foo1 Kappa", - {emoteAt(4, "Kappa")}, - "baz1 Kappa", - {emoteAt(4, "Kappa")}, - }, - { - {regularReplace("foo1", "baz1", false)}, - "FoO1 Kappa", - {emoteAt(4, "Kappa")}, - "baz1 Kappa", - {emoteAt(4, "Kappa")}, - }, - { - {regexReplace("f(o+)1", "baz1[\\1]")}, - "foo1 Kappa", - {emoteAt(4, "Kappa")}, - "baz1[oo] Kappa", - {emoteAt(8, "Kappa")}, - }, - - { - {regexReplace("f(o+)1", R"(baz1[\0][\1][\2])")}, - "foo1 Kappa", - {emoteAt(4, "Kappa")}, - "baz1[\\0][oo][\\2] Kappa", - {emoteAt(16, "Kappa")}, - }, - { - {regexReplace("f(o+)(\\d+)", "baz1[\\1+\\2]")}, - "foo123 Kappa", - {emoteAt(6, "Kappa")}, - "baz1[oo+123] Kappa", - {emoteAt(12, "Kappa")}, - }, - { - {regexReplace("(?<=foo)(\\d+)", "[\\1]")}, - "foo123 Kappa", - {emoteAt(6, "Kappa")}, - "foo[123] Kappa", - {emoteAt(8, "Kappa")}, - }, - { - {regexReplace("a(?=a| )", "b")}, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - "aaaa" - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " - "Kappa", - {emoteAt(127, "Kappa")}, - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - "bbbb" - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb " - "Kappa", - {emoteAt(127, "Kappa")}, - }, - { - {regexReplace("abc", "def", false)}, - "AbC Kappa", - {emoteAt(3, "Kappa")}, - "def Kappa", - {emoteAt(3, "Kappa")}, - }, - { - { - regexReplace("abc", "def", false), - regularReplace("def", "ghi"), - }, - "AbC Kappa", - {emoteAt(3, "Kappa")}, - "ghi Kappa", - {emoteAt(3, "Kappa")}, - }, - { - { - regexReplace("a(?=a| )", "b"), - regexReplace("b(?=b| )", "c"), - }, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - "aaaa" - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " - "Kappa", - {emoteAt(127, "Kappa")}, - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc " - "Kappa", - {emoteAt(127, "Kappa")}, - }, - }; - - for (const auto &test : testCases) - { - auto message = test.input; - auto emotes = test.twitchEmotes; - MessageBuilder::processIgnorePhrases(test.phrases, message, emotes); - - EXPECT_EQ(message, test.expectedMessage) - << "Message not equal for input '" << test.input - << "' - expected: '" << test.expectedMessage << "' got: '" - << message << "'"; - EXPECT_EQ(emotes, test.expectedTwitchEmotes) - << "Twitch emotes not equal for input '" << test.input - << "' and output '" << message << "'"; - } -} - class TestMessageBuilderP : public ::testing::TestWithParam { public: diff --git a/tests/src/TwitchIrc.cpp b/tests/src/TwitchIrc.cpp new file mode 100644 index 00000000000..8403e33a0d8 --- /dev/null +++ b/tests/src/TwitchIrc.cpp @@ -0,0 +1,336 @@ +#include "providers/twitch/TwitchIrc.hpp" + +#include "mocks/BaseApplication.hpp" +#include "mocks/Emotes.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "Test.hpp" +#include "util/IrcHelpers.hpp" + +using namespace chatterino; + +namespace { + +class MockApplication : public mock::BaseApplication +{ +public: + MockApplication() = default; + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + mock::Emotes emotes; +}; + +} // namespace + +class TestTwitchIrc : public ::testing::Test +{ +protected: + void SetUp() override + { + this->mockApplication = std::make_unique(); + } + + void TearDown() override + { + this->mockApplication.reset(); + } + + std::unique_ptr mockApplication; +}; + +TEST(TwitchIrc, CommaSeparatedListTagParsing) +{ + struct TestCase { + QString input; + std::pair expectedOutput; + }; + + std::vector testCases{ + { + "broadcaster/1", + {"broadcaster", "1"}, + }, + { + "predictions/foo/bar/baz", + {"predictions", "foo/bar/baz"}, + }, + { + "test/", + {"test", ""}, + }, + { + "/", + {"", ""}, + }, + { + "/value", + {"", "value"}, + }, + { + "", + {"", ""}, + }, + }; + + for (const auto &test : testCases) + { + auto output = slashKeyValue(test.input); + + EXPECT_EQ(output, test.expectedOutput) + << "Input " << test.input << " failed"; + } +} + +TEST(TwitchIrc, BadgeInfoParsing) +{ + struct TestCase { + QByteArray input; + std::unordered_map expectedBadgeInfo; + std::vector expectedBadges; + }; + + std::vector testCases{ + { + R"(@badge-info=predictions/<<<<<<\sHEAD[15A⸝asdf/test;badges=predictions/pink-2;client-nonce=9dbb88e516edf4efb055c011f91ea0cf;color=#FF4500;display-name=もっと頑張って;emotes=;first-msg=0;flags=;id=feb00b12-4ec5-4f77-9160-667de463dab1;mod=0;room-id=99631238;subscriber=0;tmi-sent-ts=1653494874297;turbo=0;user-id=648946956;user-type= :zniksbot!zniksbot@zniksbot.tmi.twitch.tv PRIVMSG #zneix :-tags")", + { + {"predictions", R"(<<<<<<\sHEAD[15A⸝asdf/test)"}, + }, + { + Badge{"predictions", "pink-2"}, + }, + }, + { + R"(@badge-info=predictions/<<<<<<\sHEAD[15A⸝asdf/test,founder/17;badges=predictions/pink-2,vip/1,founder/0,bits/1;client-nonce=9b836e232170a9df213aefdcb458b67e;color=#696969;display-name=NotKarar;emotes=;first-msg=0;flags=;id=e00881bd-5f21-4993-8bbd-1736cd13d42e;mod=0;room-id=99631238;subscriber=1;tmi-sent-ts=1653494879409;turbo=0;user-id=89954186;user-type= :notkarar!notkarar@notkarar.tmi.twitch.tv PRIVMSG #zneix :-tags)", + { + {"predictions", R"(<<<<<<\sHEAD[15A⸝asdf/test)"}, + {"founder", "17"}, + }, + { + Badge{"predictions", "pink-2"}, + Badge{"vip", "1"}, + Badge{"founder", "0"}, + Badge{"bits", "1"}, + }, + }, + { + R"(@badge-info=predictions/foo/bar/baz;badges=predictions/blue-1,moderator/1,glhf-pledge/1;client-nonce=f73f16228e6e32f8e92b47ab8283b7e1;color=#1E90FF;display-name=zneixbot;emotes=30259:6-12;first-msg=0;flags=;id=9682a5f1-a0b0-45e2-be9f-8074b58c5f8f;mod=1;room-id=99631238;subscriber=0;tmi-sent-ts=1653573594035;turbo=0;user-id=463521670;user-type=mod :zneixbot!zneixbot@zneixbot.tmi.twitch.tv PRIVMSG #zneix :-tags HeyGuys)", + { + {"predictions", "foo/bar/baz"}, + }, + { + Badge{"predictions", "blue-1"}, + Badge{"moderator", "1"}, + Badge{"glhf-pledge", "1"}, + }, + }, + { + R"(@badge-info=subscriber/22;badges=broadcaster/1,subscriber/18,glhf-pledge/1;color=#F97304;display-name=zneix;emotes=;first-msg=0;flags=;id=1d99f67f-a566-4416-a4e2-e85d7fce9223;mod=0;room-id=99631238;subscriber=1;tmi-sent-ts=1653612232758;turbo=0;user-id=99631238;user-type= :zneix!zneix@zneix.tmi.twitch.tv PRIVMSG #zneix :-tags)", + { + {"subscriber", "22"}, + }, + { + Badge{"broadcaster", "1"}, + Badge{"subscriber", "18"}, + Badge{"glhf-pledge", "1"}, + }, + }, + }; + + for (const auto &test : testCases) + { + auto *privmsg = + Communi::IrcPrivateMessage::fromData(test.input, nullptr); + + auto outputBadgeInfo = parseBadgeInfoTag(privmsg->tags()); + EXPECT_EQ(outputBadgeInfo, test.expectedBadgeInfo) + << "Input for badgeInfo " << test.input << " failed"; + + auto outputBadges = parseBadgeTag(privmsg->tags()); + EXPECT_EQ(outputBadges, test.expectedBadges) + << "Input for badges " << test.input << " failed"; + + delete privmsg; + } +} + +TEST_F(TestTwitchIrc, ParseTwitchEmotes) +{ + struct TestCase { + QByteArray input; + std::vector expectedTwitchEmotes; + }; + + auto *twitchEmotes = this->mockApplication->getEmotes()->getTwitchEmotes(); + + std::vector testCases{ + { + // action /me message + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=90ef1e46-8baa-4bf2-9c54-272f39d6fa11;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662206235860;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :ACTION Kappa)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"25"}, + EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }}, + }, + }, + { + R"(@badge-info=subscriber/17;badges=subscriber/12,no_audio/1;color=#EBA2C0;display-name=jammehcow;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=9c2dd916-5a6d-4c1f-9fe7-a081b62a9c6b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662201093248;turbo=0;user-id=82674227;user-type= :jammehcow!jammehcow@jammehcow.tmi.twitch.tv PRIVMSG #pajlada :Kappa)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"25"}, + EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }}, + }, + }, + { + R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=1902:0-4;first-msg=0;flags=;id=9b1c3cb9-7817-47ea-add1-f9d4a9b4f846;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201095690;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Keepo)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"1902"}, + EmoteName{"Keepo"}), // ptr + EmoteName{"Keepo"}, // name + }}, + }, + }, + { + R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=25:0-4/1902:6-10/305954156:12-19;first-msg=0;flags=;id=7be87072-bf24-4fa3-b3df-0ea6fa5f1474;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201102276;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Kappa Keepo PogChamp)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 6, // start + 10, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"1902"}, EmoteName{"Keepo"}), // ptr + EmoteName{"Keepo"}, // name + }, + { + 12, // start + 19, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"305954156"}, + EmoteName{"PogChamp"}), // ptr + EmoteName{"PogChamp"}, // name + }, + }, + }, + }, + { + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4,6-10;first-msg=0;flags=;id=f7516287-e5d1-43ca-974e-fe0cff84400b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204375009;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa Kappa)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 6, // start + 10, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + }, + }, + }, + { + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:0-4,8-12;first-msg=0;flags=;id=44f85d39-b5fb-475d-8555-f4244f2f7e82;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204423418;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa 😂 Kappa)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 9, // start - modified due to emoji + 13, // end - modified due to emoji + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + }, + }, + }, + { + // start out of range + R"(@emotes=84608:9-10 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + { + // one character emote + R"(@emotes=84608:0-0 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + { + { + 0, // start + 0, // end + twitchEmotes->getOrCreateEmote(EmoteId{"84608"}, + EmoteName{"f"}), // ptr + EmoteName{"f"}, // name + }, + }, + }, + { + // two character emote + R"(@emotes=84609:0-1 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + { + { + 0, // start + 1, // end + twitchEmotes->getOrCreateEmote(EmoteId{"84609"}, + EmoteName{"fo"}), // ptr + EmoteName{"fo"}, // name + }, + }, + }, + { + // end out of range + R"(@emotes=84608:0-15 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + { + // range bad (end character before start) + R"(@emotes=84608:15-2 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + }; + + for (const auto &test : testCases) + { + auto *privmsg = dynamic_cast( + Communi::IrcPrivateMessage::fromData(test.input, nullptr)); + ASSERT_NE(privmsg, nullptr); + QString originalMessage = privmsg->content(); + + // TODO: Add tests with replies + auto actualTwitchEmotes = + parseTwitchEmotes(privmsg->tags(), originalMessage, 0); + + EXPECT_EQ(actualTwitchEmotes, test.expectedTwitchEmotes) + << "Input for twitch emotes " << test.input << " failed"; + + delete privmsg; + } +}