diff --git a/src/controllers/highlights/HighlightBadge.cpp b/src/controllers/highlights/HighlightBadge.cpp index d00feacc..8195cbd3 100644 --- a/src/controllers/highlights/HighlightBadge.cpp +++ b/src/controllers/highlights/HighlightBadge.cpp @@ -1,5 +1,6 @@ #include "HighlightBadge.hpp" +#include "messages/SharedMessageBuilder.hpp" #include "singletons/Resources.hpp" namespace chatterino { @@ -86,21 +87,12 @@ bool HighlightBadge::compare(const QString &id, const Badge &badge) const { if (this->hasVersions_) { - auto parts = id.split("/"); - if (parts.size() == 2) - { - return parts.at(0).compare(badge.key_, Qt::CaseInsensitive) == 0 && - parts.at(1).compare(badge.value_, Qt::CaseInsensitive) == 0; - } - else - { - return parts.at(0).compare(badge.key_, Qt::CaseInsensitive) == 0; - } - } - else - { - return id.compare(badge.key_, Qt::CaseInsensitive) == 0; + auto parts = SharedMessageBuilder::slashKeyValue(id); + return parts.first.compare(badge.key_, Qt::CaseInsensitive) == 0 && + parts.second.compare(badge.value_, Qt::CaseInsensitive) == 0; } + + return id.compare(badge.key_, Qt::CaseInsensitive) == 0; } bool HighlightBadge::hasCustomSound() const diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index d3e25832..a5969daa 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -2,6 +2,7 @@ #include "common/FlagsEnum.hpp" #include "providers/twitch/TwitchBadge.hpp" +#include "util/QStringHash.hpp" #include "widgets/helper/ScrollbarHighlight.hpp" #include @@ -65,7 +66,7 @@ struct Message : boost::noncopyable { QColor usernameColor; QDateTime serverReceivedTime; std::vector badges; - std::map badgeInfos; + std::unordered_map badgeInfos; std::shared_ptr highlightColor; uint32_t count = 1; std::vector> elements; diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 6b094849..88f14095 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -4,7 +4,6 @@ #include "common/QLogging.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" -#include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" @@ -36,33 +35,6 @@ namespace { } } - QStringList parseTagList(const QVariantMap &tags, const QString &key) - { - auto iterator = tags.find(key); - if (iterator == tags.end()) - return QStringList{}; - - return iterator.value().toString().split(',', Qt::SkipEmptyParts); - } - - std::vector parseBadges(const QVariantMap &tags) - { - std::vector badges; - - for (QString badge : parseTagList(tags, "badges")) - { - QStringList parts = badge.split('/'); - if (parts.size() != 2) - { - continue; - } - - badges.emplace_back(parts[0], parts[1]); - } - - return badges; - } - } // namespace SharedMessageBuilder::SharedMessageBuilder( @@ -103,6 +75,46 @@ void SharedMessageBuilder::parse() this->message().flags.set(MessageFlag::Collapsed); } +// "foo/bar/baz,tri/hard" can be a valid badge-info tag +// In that case, valid map content should be 'split by slash' only once: +// {"foo": "bar/baz", "tri": "hard"} +std::pair SharedMessageBuilder::slashKeyValue( + const QString &kvStr) +{ + return { + // part before first slash (index 0 of section) + kvStr.section('/', 0, 0), + // part after first slash (index 1 of section) + kvStr.section('/', 1, -1), + }; +} + +std::vector SharedMessageBuilder::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 = SharedMessageBuilder::slashKeyValue(badge); + b.emplace_back(Badge{pair.first, pair.second}); + } + + return b; +} + bool SharedMessageBuilder::isIgnored() const { return isIgnoredMessage({ @@ -332,7 +344,7 @@ void SharedMessageBuilder::parseHighlights() } // Highlight because of badge - auto badges = parseBadges(this->tags); + auto badges = this->parseBadgeTag(this->tags); auto badgeHighlights = getCSettings().highlightedBadges.readOnly(); bool badgeHighlightSet = false; for (const HighlightBadge &highlight : *badgeHighlights) diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp index 2bee11fc..62adb45d 100644 --- a/src/messages/SharedMessageBuilder.hpp +++ b/src/messages/SharedMessageBuilder.hpp @@ -3,6 +3,7 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" #include "messages/MessageColor.hpp" +#include "providers/twitch/TwitchBadge.hpp" #include #include @@ -32,6 +33,11 @@ public: virtual void triggerHighlights(); virtual MessagePtr build() = 0; + static std::pair slashKeyValue(const QString &kvStr); + + // Parses "badges" tag which contains a comma separated list of key-value elements + static std::vector parseBadgeTag(const QVariantMap &tags); + protected: virtual void parse(); diff --git a/src/providers/twitch/TwitchBadge.cpp b/src/providers/twitch/TwitchBadge.cpp index b48d3cb7..0a879992 100644 --- a/src/providers/twitch/TwitchBadge.cpp +++ b/src/providers/twitch/TwitchBadge.cpp @@ -33,4 +33,9 @@ Badge::Badge(QString key, QString value) } } +bool Badge::operator==(const Badge &other) const +{ + return this->key_ == other.key_ && this->value_ == other.value_; +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchBadge.hpp b/src/providers/twitch/TwitchBadge.hpp index 764be2e7..f3267687 100644 --- a/src/providers/twitch/TwitchBadge.hpp +++ b/src/providers/twitch/TwitchBadge.hpp @@ -11,9 +11,13 @@ class Badge public: Badge(QString key, QString value); - QString key_; // e.g. bits - QString value_; // e.g. 100 - QString extraValue_{}; // e.g. 5 (the number of months subscribed) + bool operator==(const Badge &other) const; + + // Class members are fetched from both "badges" and "badge-info" tags + // E.g.: "badges": "subscriber/18", "badge-info": "subscriber/22" + QString key_; // subscriber + QString value_; // 18 + //QString info_; // 22 (should be parsed separetly into an std::unordered_map) MessageElementFlag flag_{ MessageElementFlag::BadgeVanity}; // badge slot it takes up }; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 4d831252..aadaa411 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -7,6 +7,7 @@ #include "messages/Message.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/ffz/FfzBadges.hpp" +#include "providers/twitch/TwitchBadge.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -47,55 +48,6 @@ const QSet zeroWidthEmotes{ namespace chatterino { -namespace { - - QStringList parseTagList(const QVariantMap &tags, const QString &key) - { - auto iterator = tags.find(key); - if (iterator == tags.end()) - return QStringList{}; - - return iterator.value().toString().split(',', Qt::SkipEmptyParts); - } - - std::map parseBadgeInfos(const QVariantMap &tags) - { - std::map badgeInfos; - - for (QString badgeInfo : parseTagList(tags, "badge-info")) - { - QStringList parts = badgeInfo.split('/'); - if (parts.size() != 2) - { - continue; - } - - badgeInfos.emplace(parts[0], parts[1]); - } - - return badgeInfos; - } - - std::vector parseBadges(const QVariantMap &tags) - { - std::vector badges; - - for (QString badge : parseTagList(tags, "badges")) - { - QStringList parts = badge.split('/'); - if (parts.size() != 2) - { - continue; - } - - badges.emplace_back(parts[0], parts[1]); - } - - return badges; - } - -} // namespace - TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) @@ -1033,6 +985,25 @@ boost::optional TwitchMessageBuilder::getTwitchBadge( return boost::none; } +std::unordered_map TwitchMessageBuilder::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(SharedMessageBuilder::slashKeyValue(badge)); + } + + return infoMap; +} + void TwitchMessageBuilder::appendTwitchBadges() { if (this->twitchChannel == nullptr) @@ -1040,8 +1011,8 @@ void TwitchMessageBuilder::appendTwitchBadges() return; } - auto badgeInfos = parseBadgeInfos(this->tags); - auto badges = parseBadges(this->tags); + auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); + auto badges = this->parseBadgeTag(this->tags); for (const auto &badge : badges) { @@ -1091,7 +1062,7 @@ void TwitchMessageBuilder::appendTwitchBadges() // (tier + amount of months with leading zero if less than 100) // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub const auto &subTier = - badge.value_.length() > 3 ? badge.value_.front() : '1'; + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; const auto &subMonths = badgeInfoIt->second; tooltip += QString(" (%1%2 months)") @@ -1107,9 +1078,9 @@ void TwitchMessageBuilder::appendTwitchBadges() { auto predictionText = badgeInfoIt->second - .replace("\\s", " ") // standard IRC escapes - .replace("\\:", ";") - .replace("\\\\", "\\") + .replace(R"(\s)", " ") // standard IRC escapes + .replace(R"(\:)", ";") + .replace(R"(\\)", R"(\)") .replace("⸝", ","); // twitch's comma escape // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 0d4091d5..c96712d6 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -68,6 +68,10 @@ public: Channel *channel, MessageBuilder *builder); + // Shares some common logic from SharedMessageBuilder::parseBadgeTag + static std::unordered_map parseBadgeInfoTag( + const QVariantMap &tags); + private: void parseUsernameColor() override; void parseUsername() override; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 61e583f4..58db955e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -17,6 +17,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/UtilTwitch.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IrcHelpers.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchPubSubClient.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/TwitchMessageBuilder.cpp # Add your new file above this line! ) diff --git a/tests/src/TwitchMessageBuilder.cpp b/tests/src/TwitchMessageBuilder.cpp new file mode 100644 index 00000000..24eb4264 --- /dev/null +++ b/tests/src/TwitchMessageBuilder.cpp @@ -0,0 +1,130 @@ +#include "providers/twitch/TwitchMessageBuilder.hpp" + +#include "common/Channel.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchBadge.hpp" + +#include "ircconnection.h" + +#include +#include +#include +#include +#include + +using namespace chatterino; + +TEST(TwitchMessageBuilder, 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 = TwitchMessageBuilder::slashKeyValue(test.input); + + EXPECT_EQ(output, test.expectedOutput) + << "Input " << test.input.toStdString() << " failed"; + } +} + +TEST(TwitchMessageBuilder, 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 = + TwitchMessageBuilder::parseBadgeInfoTag(privmsg->tags()); + EXPECT_EQ(outputBadgeInfo, test.expectedBadgeInfo) + << "Input for badgeInfo " << test.input.toStdString() << " failed"; + + auto outputBadges = + SharedMessageBuilder::parseBadgeTag(privmsg->tags()); + EXPECT_EQ(outputBadges, test.expectedBadges) + << "Input for badges " << test.input.toStdString() << " failed"; + } +}