#include "providers/twitch/TwitchMessageBuilder.hpp" #include "Application.hpp" #include "common/LinkParser.hpp" #include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/userdata/UserDataController.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageThread.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/colors/ColorProvider.hpp" #include "providers/ffz/FfzBadges.hpp" #include "providers/seventv/SeventvBadges.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" #include "util/QStringHash.hpp" #include "util/Qt.hpp" #include "widgets/Window.hpp" #include #include #include #include #include using namespace chatterino::literals; namespace { using namespace std::chrono_literals; const QString regexHelpString("(\\w+)[.,!?;:]*?$"); // matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" const QRegularExpression mentionRegex("^@" + regexHelpString); // if findAllUsernames setting is enabled, matches strings like in the examples above, but without @ symbol at the beginning const QRegularExpression allUsernamesMentionRegex("^" + regexHelpString); const QSet zeroWidthEmotes{ "SoSnowy", "IceCold", "SantaHat", "TopHat", "ReinDeer", "CandyCane", "cvMask", "cvHazmat", }; struct HypeChatPaidLevel { std::chrono::seconds duration; uint8_t numeric; }; const std::unordered_map HYPE_CHAT_PAID_LEVEL{ {u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}}, {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}}, {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}}, {u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}}, {u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}}, }; } // namespace namespace chatterino { namespace { void appendTwitchEmoteOccurrences(const QString &emote, std::vector &vec, const std::vector &correctPositions, const QString &originalMessage, int messageOffset) { auto *app = getIApp(); 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 TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) : SharedMessageBuilder(_channel, _ircMessage, _args) , twitchChannel(dynamic_cast(_channel)) { } TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcMessage *_ircMessage, const MessageParseArgs &_args, QString content, bool isAction) : SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction) , twitchChannel(dynamic_cast(_channel)) { } bool TwitchMessageBuilder::isIgnored() const { return isIgnoredMessage({ /*.message = */ this->originalMessage_, /*.twitchUserID = */ this->tags.value("user-id").toString(), /*.isMod = */ this->channel->isMod(), /*.isBroadcaster = */ this->channel->isBroadcaster(), }); } bool TwitchMessageBuilder::isIgnoredReply() const { return isIgnoredMessage({ /*.message = */ this->originalMessage_, /*.twitchUserID = */ this->tags.value("reply-parent-user-id").toString(), /*.isMod = */ this->channel->isMod(), /*.isBroadcaster = */ this->channel->isBroadcaster(), }); } void TwitchMessageBuilder::triggerHighlights() { if (this->historicalMessage_) { // Do nothing. Highlights should not be triggered on historical messages. return; } SharedMessageBuilder::triggerHighlights(); } MessagePtr TwitchMessageBuilder::build() { assert(this->ircMessage != nullptr); assert(this->channel != nullptr); // PARSE this->userId_ = this->ircMessage->tag("user-id").toString(); this->parse(); if (this->userName == this->channel->getName()) { this->senderIsBroadcaster = true; } this->message().channelName = this->channel->getName(); this->parseMessageID(); this->parseRoomID(); // If it is a reward it has to be appended first if (this->args.channelPointRewardId != "") { const auto &reward = this->twitchChannel->channelPointReward( this->args.channelPointRewardId); if (reward) { TwitchMessageBuilder::appendChannelPointRewardMessage( *reward, this, this->channel->isMod(), this->channel->isBroadcaster()); } } this->appendChannelName(); if (this->tags.contains("rm-deleted")) { this->message().flags.set(MessageFlag::Disabled); } this->historicalMessage_ = this->tags.contains("historical"); if (this->tags.contains("msg-id") && this->tags["msg-id"].toString().split(';').contains( "highlighted-message")) { this->message().flags.set(MessageFlag::RedeemedHighlight); } if (this->tags.contains("first-msg") && this->tags["first-msg"].toString() == "1") { this->message().flags.set(MessageFlag::FirstMessage); } if (this->tags.contains("pinned-chat-paid-amount")) { this->message().flags.set(MessageFlag::ElevatedMessage); } if (this->tags.contains("bits")) { this->message().flags.set(MessageFlag::CheerMessage); } // reply threads this->parseThread(); // timestamp this->message().serverReceivedTime = calculateMessageTime(this->ircMessage); this->emplace(this->message().serverReceivedTime.time()); if (this->shouldAddModerationElements()) { this->emplace(); } this->appendTwitchBadges(); this->appendChatterinoBadges(); this->appendFfzBadges(); this->appendSeventvBadges(); this->appendUsername(); // QString bits; auto iterator = this->tags.find("bits"); if (iterator != this->tags.end()) { this->hasBits_ = true; this->bitsLeft = iterator.value().toInt(); this->bits = iterator.value().toString(); } // Twitch emotes auto twitchEmotes = TwitchMessageBuilder::parseTwitchEmotes( this->tags, this->originalMessage_, this->messageOffset_); // This runs through all ignored phrases and runs its replacements on this->originalMessage_ this->runIgnoreReplaces(twitchEmotes); std::sort(twitchEmotes.begin(), twitchEmotes.end(), [](const auto &a, const auto &b) { return a.start < b.start; }); twitchEmotes.erase(std::unique(twitchEmotes.begin(), twitchEmotes.end(), [](const auto &first, const auto &second) { return first.start == second.start; }), twitchEmotes.end()); // words QStringList splits = this->originalMessage_.split(' '); this->addWords(splits, twitchEmotes); QString stylizedUsername = this->stylizeUsername(this->userName, this->message()); this->message().messageText = this->originalMessage_; this->message().searchText = stylizedUsername + " " + this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; // highlights this->parseHighlights(); // highlighting incoming whispers if requested per setting if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) { this->message().flags.set(MessageFlag::HighlightedWhisper, true); this->message().highlightColor = ColorProvider::instance().color(ColorType::Whisper); } if (this->thread_) { auto &img = getResources().buttons.replyThreadDark; this->emplace( Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, MessageElementFlag::ReplyButton) ->setLink({Link::ViewThread, this->thread_->rootId()}); } else { auto &img = getResources().buttons.replyDark; this->emplace( Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, MessageElementFlag::ReplyButton) ->setLink({Link::ReplyToMessage, this->message().id}); } return this->release(); } bool doesWordContainATwitchEmote( int cursor, const QString &word, const std::vector &twitchEmotes, std::vector::const_iterator ¤tTwitchEmoteIt) { if (currentTwitchEmoteIt == twitchEmotes.end()) { // No emote to add! return false; } const auto ¤tTwitchEmote = *currentTwitchEmoteIt; auto wordEnd = cursor + word.length(); // Check if this emote fits within the word boundaries if (currentTwitchEmote.start < cursor || currentTwitchEmote.end > wordEnd) { // this emote does not fit xd return false; } return true; } void TwitchMessageBuilder::addWords( const QStringList &words, const std::vector &twitchEmotes) { // cursor currently indicates what character index we're currently operating in the full list of words int cursor = 0; auto currentTwitchEmoteIt = twitchEmotes.begin(); for (auto word : words) { if (word.isEmpty()) { cursor++; continue; } while (doesWordContainATwitchEmote(cursor, word, twitchEmotes, currentTwitchEmoteIt)) { const auto ¤tTwitchEmote = *currentTwitchEmoteIt; if (currentTwitchEmote.start == cursor) { // This emote exists right at the start of the word! this->emplace(currentTwitchEmote.ptr, MessageElementFlag::TwitchEmote, this->textColor_); auto len = currentTwitchEmote.name.string.length(); cursor += len; word = word.mid(len); ++currentTwitchEmoteIt; if (word.isEmpty()) { // space cursor += 1; break; } else { this->message().elements.back()->setTrailingSpace(false); } continue; } // Emote is not at the start // 1. Add text before the emote QString preText = word.left(currentTwitchEmote.start - cursor); for (auto &variant : getIApp()->getEmotes()->getEmojis()->parse(preText)) { boost::apply_visitor( [&](auto &&arg) { this->addTextOrEmoji(arg); }, variant); } cursor += preText.size(); word = word.mid(preText.size()); } if (word.isEmpty()) { continue; } // split words for (auto &variant : getIApp()->getEmotes()->getEmojis()->parse(word)) { boost::apply_visitor( [&](auto &&arg) { this->addTextOrEmoji(arg); }, variant); } cursor += word.size() + 1; } } void TwitchMessageBuilder::addTextOrEmoji(EmotePtr emote) { return SharedMessageBuilder::addTextOrEmoji(emote); } void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) { auto string = QString(string_); if (this->hasBits_ && this->tryParseCheermote(string)) { // This string was parsed as a cheermote return; } // TODO: Implement ignored emotes // Format of ignored emotes: // Emote name: "forsenPuke" - if string in ignoredEmotes // Will match emote regardless of source (i.e. bttv, ffz) // Emote source + name: "bttv:nyanPls" if (this->tryAppendEmote({string})) { // Successfully appended an emote return; } // Actually just text LinkParser parsed(string); auto textColor = this->textColor_; if (parsed.result()) { this->addLink(*parsed.result()); return; } if (string.startsWith('@')) { auto match = mentionRegex.match(string); // Only treat as @mention if valid username if (match.hasMatch()) { QString username = match.captured(1); auto originalTextColor = textColor; if (this->twitchChannel != nullptr && getSettings()->colorUsernames) { if (auto userColor = this->twitchChannel->getUserColor(username); userColor.isValid()) { textColor = userColor; } } auto prefixedUsername = '@' + username; this->emplace(prefixedUsername, MessageElementFlag::BoldUsername, textColor, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); this->emplace(prefixedUsername, MessageElementFlag::NonBoldUsername, textColor) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); this->emplace(string.remove(prefixedUsername), MessageElementFlag::Text, originalTextColor); return; } } if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) { auto match = allUsernamesMentionRegex.match(string); QString username = match.captured(1); if (match.hasMatch() && this->twitchChannel->accessChatters()->contains(username)) { auto originalTextColor = textColor; if (getSettings()->colorUsernames) { if (auto userColor = this->twitchChannel->getUserColor(username); userColor.isValid()) { textColor = userColor; } } this->emplace(username, MessageElementFlag::BoldUsername, textColor, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); this->emplace( username, MessageElementFlag::NonBoldUsername, textColor) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); this->emplace(string.remove(username), MessageElementFlag::Text, originalTextColor); return; } } this->emplace(string, MessageElementFlag::Text, textColor); } void TwitchMessageBuilder::parseMessageID() { auto iterator = this->tags.find("id"); if (iterator != this->tags.end()) { this->message().id = iterator.value().toString(); } } void TwitchMessageBuilder::parseRoomID() { if (this->twitchChannel == nullptr) { return; } auto iterator = this->tags.find("room-id"); if (iterator != std::end(this->tags)) { this->roomID_ = iterator.value().toString(); if (this->twitchChannel->roomId().isEmpty()) { this->twitchChannel->setRoomId(this->roomID_); } } } void TwitchMessageBuilder::parseThread() { if (this->thread_) { // set references this->message().replyThread = this->thread_; this->message().replyParent = this->parent_; this->thread_->addToThread(this->weakOf()); // enable reply flag this->message().flags.set(MessageFlag::ReplyMessage); MessagePtr threadRoot; if (!this->parent_) { threadRoot = this->thread_->root(); } else { threadRoot = this->parent_; } QString usernameText = SharedMessageBuilder::stylizeUsername( threadRoot->loginName, *threadRoot); this->emplace(); // construct reply elements this->emplace( "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall) ->setLink({Link::ViewThread, this->thread_->rootId()}); this->emplace( "@" + usernameText + ":", MessageElementFlag::RepliedMessage, threadRoot->usernameColor, FontStyle::ChatMediumSmall) ->setLink({Link::UserInfo, threadRoot->displayName}); this->emplace( threadRoot->messageText, MessageElementFlags({MessageElementFlag::RepliedMessage, MessageElementFlag::Text}), this->textColor_, FontStyle::ChatMediumSmall) ->setLink({Link::ViewThread, this->thread_->rootId()}); } else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) { // Message is a reply but we couldn't find the original message. // Render the message using the additional reply tags auto replyDisplayName = this->tags.find("reply-parent-display-name"); auto replyBody = this->tags.find("reply-parent-msg-body"); if (replyDisplayName != this->tags.end() && replyBody != this->tags.end()) { QString body; this->emplace(); this->emplace( "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall); if (this->isIgnoredReply()) { body = QString("[Blocked user]"); } else { auto name = replyDisplayName->toString(); body = parseTagString(replyBody->toString()); this->emplace( "@" + name + ":", MessageElementFlag::RepliedMessage, this->textColor_, FontStyle::ChatMediumSmall) ->setLink({Link::UserInfo, name}); } this->emplace( body, MessageElementFlags({MessageElementFlag::RepliedMessage, MessageElementFlag::Text}), this->textColor_, FontStyle::ChatMediumSmall); } } } void TwitchMessageBuilder::parseUsernameColor() { const auto *userData = getIApp()->getUserData(); assert(userData != nullptr); if (const auto &user = userData->getUser(this->userId_)) { if (user->color) { this->usernameColor_ = user->color.value(); return; } } const auto iterator = this->tags.find("color"); if (iterator != this->tags.end()) { if (const auto color = iterator.value().toString(); !color.isEmpty()) { this->usernameColor_ = QColor(color); this->message().usernameColor = this->usernameColor_; return; } } if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) { this->usernameColor_ = getRandomColor(this->tags.value("user-id").toString()); this->message().usernameColor = this->usernameColor_; } } void TwitchMessageBuilder::parseUsername() { SharedMessageBuilder::parseUsername(); if (this->userName.isEmpty() || this->args.trimSubscriberUsername) { this->userName = this->tags.value(QLatin1String("login")).toString(); } // display name // auto displayNameVariant = this->tags.value("display-name"); // if (displayNameVariant.isValid()) { // this->userName = displayNameVariant.toString() + " (" + // this->userName + ")"; // } this->message().loginName = this->userName; if (this->twitchChannel != nullptr) { this->twitchChannel->setUserColor(this->userName, this->usernameColor_); } // Update current user color if this is our message auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (this->ircMessage->nick() == currentUser->getUserName()) { currentUser->setColor(this->usernameColor_); } } void TwitchMessageBuilder::appendUsername() { auto *app = getIApp(); QString username = this->userName; this->message().loginName = username; QString localizedName; auto iterator = this->tags.find("display-name"); if (iterator != this->tags.end()) { QString displayName = parseTagString(iterator.value().toString()).trimmed(); if (QString::compare(displayName, this->userName, Qt::CaseInsensitive) == 0) { username = displayName; this->message().displayName = displayName; } else { localizedName = displayName; this->message().displayName = username; this->message().localizedName = displayName; } } QString usernameText = SharedMessageBuilder::stylizeUsername(username, this->message()); if (this->args.isSentWhisper) { // TODO(pajlada): Re-implement // userDisplayString += // IrcManager::instance().getUser().getUserName(); } else if (this->args.isReceivedWhisper) { // Sender username this->emplace(usernameText, MessageElementFlag::Username, this->usernameColor_, FontStyle::ChatMediumBold) ->setLink({Link::UserWhisper, this->message().displayName}); auto currentUser = app->getAccounts()->twitch.getCurrent(); // Separator this->emplace("->", MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMedium); QColor selfColor = currentUser->color(); MessageColor selfMsgColor = selfColor.isValid() ? selfColor : MessageColor::System; // Your own username this->emplace(currentUser->getUserName() + ":", MessageElementFlag::Username, selfMsgColor, FontStyle::ChatMediumBold); } else { if (!this->action_) { usernameText += ":"; } this->emplace(usernameText, MessageElementFlag::Username, this->usernameColor_, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, this->message().displayName}); } } void TwitchMessageBuilder::runIgnoreReplaces( std::vector &twitchEmotes) { using SizeType = QString::size_type; auto phrases = getSettings()->ignoredMessages.readOnly(); 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); this->originalMessage_.replace(from, length, replacement); auto wordStart = from; while (wordStart > 0) { if (this->originalMessage_[wordStart - 1] == ' ') { break; } --wordStart; } auto wordEnd = from + replacement.length(); while (wordEnd < this->originalMessage_.length()) { if (this->originalMessage_[wordEnd] == ' ') { break; } ++wordEnd; } shiftIndicesAfter(static_cast(from + length), static_cast(replacement.length() - length)); #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) auto midExtendedRef = QStringView{this->originalMessage_}.mid( wordStart, wordEnd - wordStart); #else auto midExtendedRef = this->originalMessage_.midRef(wordStart, wordEnd - wordStart); #endif 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); auto match = emoteregex.match(midExtendedRef); 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 = this->originalMessage_.indexOf(regex, from, &match)) != -1) { replaceMessageAt(phrase, from, match.capturedLength(), phrase.getReplace()); from += phrase.getReplace().length(); iterations++; if (iterations >= 128) { this->originalMessage_ = u"Too many replacements - check your ignores!"_s; return; } } continue; } SizeType from = 0; while ((from = this->originalMessage_.indexOf( pattern, from, phrase.caseSensitivity())) != -1) { replaceMessageAt(phrase, from, pattern.length(), phrase.getReplace()); from += phrase.getReplace().length(); } } } Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) { auto *app = getIApp(); const auto &globalBttvEmotes = app->getTwitch()->getBttvEmotes(); const auto &globalFfzEmotes = app->getTwitch()->getFfzEmotes(); const auto &globalSeventvEmotes = app->getTwitch()->getSeventvEmotes(); auto flags = MessageElementFlags(); auto emote = std::optional{}; bool zeroWidth = false; // Emote order: // - FrankerFaceZ Channel // - BetterTTV Channel // - 7TV Channel // - FrankerFaceZ Global // - BetterTTV Global // - 7TV Global if (this->twitchChannel && (emote = this->twitchChannel->ffzEmote(name))) { flags = MessageElementFlag::FfzEmote; } else if (this->twitchChannel && (emote = this->twitchChannel->bttvEmote(name))) { flags = MessageElementFlag::BttvEmote; } else if (this->twitchChannel != nullptr && (emote = this->twitchChannel->seventvEmote(name))) { flags = MessageElementFlag::SevenTVEmote; zeroWidth = emote.value()->zeroWidth; } else if ((emote = globalFfzEmotes.emote(name))) { flags = MessageElementFlag::FfzEmote; } else if ((emote = globalBttvEmotes.emote(name))) { flags = MessageElementFlag::BttvEmote; zeroWidth = zeroWidthEmotes.contains(name.string); } else if ((emote = globalSeventvEmotes.globalEmote(name))) { flags = MessageElementFlag::SevenTVEmote; zeroWidth = emote.value()->zeroWidth; } if (emote) { if (zeroWidth && getSettings()->enableZeroWidthEmotes && !this->isEmpty()) { // Attempt to merge current zero-width emote into any previous emotes auto *asEmote = dynamic_cast(&this->back()); if (asEmote) { // Make sure to access asEmote before taking ownership when releasing auto baseEmote = asEmote->getEmote(); // Need to remove EmoteElement and replace with LayeredEmoteElement auto baseEmoteElement = this->releaseBack(); std::vector layers = { {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; this->emplace( std::move(layers), baseEmoteElement->getFlags() | flags, this->textColor_); return Success; } auto *asLayered = dynamic_cast(&this->back()); if (asLayered) { asLayered->addEmoteLayer({*emote, flags}); asLayered->addFlags(flags); return Success; } // No emote to merge with, just show as regular emote } this->emplace(*emote, flags, this->textColor_); return Success; } return Failure; } std::optional TwitchMessageBuilder::getTwitchBadge( const Badge &badge) const { if (auto channelBadge = this->twitchChannel->twitchBadge(badge.key_, badge.value_)) { return channelBadge; } if (auto globalBadge = TwitchBadges::instance()->badge(badge.key_, badge.value_)) { return globalBadge; } return std::nullopt; } std::unordered_map 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; } std::vector TwitchMessageBuilder::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 TwitchMessageBuilder::appendTwitchBadges() { if (this->twitchChannel == nullptr) { return; } auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); auto badges = this->parseBadgeTag(this->tags); for (const auto &badge : badges) { auto badgeEmote = this->getTwitchBadge(badge); if (!badgeEmote) { continue; } auto tooltip = (*badgeEmote)->tooltip.string; if (badge.key_ == "bits") { const auto &cheerAmount = badge.value_; tooltip = QString("Twitch cheer %0").arg(cheerAmount); } else if (badge.key_ == "moderator" && getSettings()->useCustomFfzModeratorBadges) { if (auto customModBadge = this->twitchChannel->ffzCustomModBadge()) { this->emplace( *customModBadge, MessageElementFlag::BadgeChannelAuthority) ->setTooltip((*customModBadge)->tooltip.string); // early out, since we have to add a custom badge element here continue; } } else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges) { if (auto customVipBadge = this->twitchChannel->ffzCustomVipBadge()) { this->emplace( *customVipBadge, MessageElementFlag::BadgeChannelAuthority) ->setTooltip((*customVipBadge)->tooltip.string); // early out, since we have to add a custom badge element here continue; } } else if (badge.flag_ == MessageElementFlag::BadgeSubscription) { auto badgeInfoIt = badgeInfos.find(badge.key_); if (badgeInfoIt != badgeInfos.end()) { // badge.value_ is 4 chars long if user is subbed on higher tier // (tier + amount of months with leading zero if less than 100) // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub const auto &subTier = badge.value_.length() > 3 ? badge.value_.at(0) : '1'; const auto &subMonths = badgeInfoIt->second; tooltip += QString(" (%1%2 months)") .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) : "") .arg(subMonths); } } else if (badge.flag_ == MessageElementFlag::BadgePredictions) { auto badgeInfoIt = badgeInfos.find(badge.key_); if (badgeInfoIt != badgeInfos.end()) { auto predictionText = badgeInfoIt->second .replace(R"(\s)", " ") // standard IRC escapes .replace(R"(\:)", ";") .replace(R"(\\)", R"(\)") .replace("⸝", ","); // twitch's comma escape // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma tooltip = QString("Predicted %1").arg(predictionText); } } this->emplace(*badgeEmote, badge.flag_) ->setTooltip(tooltip); } this->message().badges = badges; this->message().badgeInfos = badgeInfos; } void TwitchMessageBuilder::appendChatterinoBadges() { if (auto badge = getIApp()->getChatterinoBadges()->getBadge({this->userId_})) { this->emplace(*badge, MessageElementFlag::BadgeChatterino); } } void TwitchMessageBuilder::appendFfzBadges() { for (const auto &badge : getIApp()->getFfzBadges()->getUserBadges({this->userId_})) { this->emplace( badge.emote, MessageElementFlag::BadgeFfz, badge.color); } } void TwitchMessageBuilder::appendSeventvBadges() { if (auto badge = getIApp()->getSeventvBadges()->getBadge({this->userId_})) { this->emplace(*badge, MessageElementFlag::BadgeSevenTV); } } Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) { if (this->bitsLeft == 0) { return Failure; } auto cheerOpt = this->twitchChannel->cheerEmote(string); if (!cheerOpt) { return Failure; } auto &cheerEmote = *cheerOpt; auto match = cheerEmote.regex.match(string); if (!match.hasMatch()) { return Failure; } int cheerValue = match.captured(1).toInt(); if (getSettings()->stackBits) { if (this->bitsStacked) { return Success; } if (cheerEmote.staticEmote) { this->emplace(cheerEmote.staticEmote, MessageElementFlag::BitsStatic, this->textColor_); } if (cheerEmote.animatedEmote) { this->emplace(cheerEmote.animatedEmote, MessageElementFlag::BitsAnimated, this->textColor_); } if (cheerEmote.color != QColor()) { this->emplace(QString::number(this->bitsLeft), MessageElementFlag::BitsAmount, cheerEmote.color); } this->bitsStacked = true; return Success; } if (this->bitsLeft >= cheerValue) { this->bitsLeft -= cheerValue; } else { QString newString = string; newString.chop(QString::number(cheerValue).length()); newString += QString::number(cheerValue - this->bitsLeft); return tryParseCheermote(newString); } if (cheerEmote.staticEmote) { this->emplace(cheerEmote.staticEmote, MessageElementFlag::BitsStatic, this->textColor_); } if (cheerEmote.animatedEmote) { this->emplace(cheerEmote.animatedEmote, MessageElementFlag::BitsAnimated, this->textColor_); } if (cheerEmote.color != QColor()) { this->emplace(match.captured(1), MessageElementFlag::BitsAmount, cheerEmote.color); } return Success; } bool TwitchMessageBuilder::shouldAddModerationElements() const { if (this->senderIsBroadcaster) { // You cannot timeout the broadcaster return false; } if (this->tags.value("user-type").toString() == "mod" && !this->args.isStaffOrBroadcaster) { // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel return false; } return true; } void TwitchMessageBuilder::appendChannelPointRewardMessage( const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, bool isBroadcaster) { if (isIgnoredMessage({ /*.message = */ "", /*.twitchUserID = */ reward.user.id, /*.isMod = */ isMod, /*.isBroadcaster = */ isBroadcaster, })) { return; } builder->emplace(); QString redeemed = "Redeemed"; QStringList textList; if (!reward.isUserInputRequired) { builder ->emplace( reward.user.login, MessageElementFlag::ChannelPointReward, MessageColor::Text, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, reward.user.login}); redeemed = "redeemed"; textList.append(reward.user.login); } builder->emplace(redeemed, MessageElementFlag::ChannelPointReward); builder->emplace( reward.title, MessageElementFlag::ChannelPointReward, MessageColor::Text, FontStyle::ChatMediumBold); builder->emplace( reward.image, MessageElementFlag::ChannelPointRewardImage); builder->emplace( QString::number(reward.cost), MessageElementFlag::ChannelPointReward, MessageColor::Text, FontStyle::ChatMediumBold); if (reward.isUserInputRequired) { builder->emplace( MessageElementFlag::ChannelPointReward); } builder->message().flags.set(MessageFlag::RedeemedChannelPointReward); textList.append({redeemed, reward.title, QString::number(reward.cost)}); builder->message().messageText = textList.join(" "); builder->message().searchText = textList.join(" "); builder->message().loginName = reward.user.login; } void TwitchMessageBuilder::liveMessage(const QString &channelName, MessageBuilder *builder) { builder->emplace(); builder ->emplace(channelName, MessageElementFlag::Username, MessageColor::Text, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, channelName}); builder->emplace("is live!", MessageElementFlag::Text, MessageColor::Text); auto text = QString("%1 is live!").arg(channelName); builder->message().messageText = text; builder->message().searchText = text; } void TwitchMessageBuilder::liveSystemMessage(const QString &channelName, MessageBuilder *builder) { builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder ->emplace(channelName, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, channelName}); builder->emplace("is live!", MessageElementFlag::Text, MessageColor::System); auto text = QString("%1 is live!").arg(channelName); builder->message().messageText = text; builder->message().searchText = text; } void TwitchMessageBuilder::offlineSystemMessage(const QString &channelName, MessageBuilder *builder) { builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder ->emplace(channelName, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, channelName}); builder->emplace("is now offline.", MessageElementFlag::Text, MessageColor::System); auto text = QString("%1 is now offline.").arg(channelName); builder->message().messageText = text; builder->message().searchText = text; } void TwitchMessageBuilder::hostingSystemMessage(const QString &channelName, MessageBuilder *builder, bool hostOn) { QString text; builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); if (hostOn) { builder->emplace("Now hosting", MessageElementFlag::Text, MessageColor::System); builder ->emplace( channelName + ".", MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, channelName}); text = QString("Now hosting %1.").arg(channelName); } else { builder ->emplace(channelName, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, channelName}); builder->emplace("has gone offline. Exiting host mode.", MessageElementFlag::Text, MessageColor::System); text = QString("%1 has gone offline. Exiting host mode.").arg(channelName); } builder->message().messageText = text; builder->message().searchText = text; } // IRC variant void TwitchMessageBuilder::deletionMessage(const MessagePtr originalMessage, MessageBuilder *builder) { builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder->message().flags.set(MessageFlag::Timeout); // TODO(mm2pl): If or when jumping to a single message gets implemented a link, // add a link to the originalMessage builder->emplace("A message from", MessageElementFlag::Text, MessageColor::System); builder ->emplace(originalMessage->displayName, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, originalMessage->loginName}); builder->emplace("was deleted:", MessageElementFlag::Text, MessageColor::System); if (originalMessage->messageText.length() > 50) { builder ->emplace(originalMessage->messageText.left(50) + "…", MessageElementFlag::Text, MessageColor::Text) ->setLink({Link::JumpToMessage, originalMessage->id}); } else { builder ->emplace(originalMessage->messageText, MessageElementFlag::Text, MessageColor::Text) ->setLink({Link::JumpToMessage, originalMessage->id}); } builder->message().timeoutUser = "msg:" + originalMessage->id; } // pubsub variant void TwitchMessageBuilder::deletionMessage(const DeleteAction &action, MessageBuilder *builder) { builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder->message().flags.set(MessageFlag::Timeout); builder ->emplace(action.source.login, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, action.source.login}); // TODO(mm2pl): If or when jumping to a single message gets implemented a link, // add a link to the originalMessage builder->emplace( "deleted message from", MessageElementFlag::Text, MessageColor::System); builder ->emplace(action.target.login, MessageElementFlag::Username, MessageColor::System, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, action.target.login}); builder->emplace("saying:", MessageElementFlag::Text, MessageColor::System); if (action.messageText.length() > 50) { builder ->emplace(action.messageText.left(50) + "…", MessageElementFlag::Text, MessageColor::Text) ->setLink({Link::JumpToMessage, action.messageId}); } else { builder ->emplace(action.messageText, MessageElementFlag::Text, MessageColor::Text) ->setLink({Link::JumpToMessage, action.messageId}); } builder->message().timeoutUser = "msg:" + action.messageId; } void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, QStringList users, Channel *channel, MessageBuilder *builder) { QString text = prefix + users.join(", "); builder->message().messageText = text; builder->message().searchText = text; builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder->emplace(prefix, MessageElementFlag::Text, MessageColor::System); bool isFirst = true; auto tc = dynamic_cast(channel); for (const QString &username : users) { if (!isFirst) { // this is used to add the ", " after each but the last entry builder->emplace(",", MessageElementFlag::Text, MessageColor::System); } isFirst = false; MessageColor color = MessageColor::System; if (tc && getSettings()->colorUsernames) { if (auto userColor = tc->getUserColor(username); userColor.isValid()) { color = MessageColor(userColor); } } builder ->emplace(username, MessageElementFlag::BoldUsername, color, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); builder ->emplace(username, MessageElementFlag::NonBoldUsername, color) ->setLink({Link::UserInfo, username}) ->setTrailingSpace(false); } } void TwitchMessageBuilder::listOfUsersSystemMessage( QString prefix, const std::vector &users, Channel *channel, MessageBuilder *builder) { QString text = prefix; builder->emplace(); builder->message().flags.set(MessageFlag::System); builder->message().flags.set(MessageFlag::DoNotTriggerNotification); builder->emplace(prefix, MessageElementFlag::Text, MessageColor::System); bool isFirst = true; auto *tc = dynamic_cast(channel); for (const auto &user : users) { if (!isFirst) { // this is used to add the ", " after each but the last entry builder->emplace(",", MessageElementFlag::Text, MessageColor::System); text += QString(", %1").arg(user.userName); } else { text += user.userName; } isFirst = false; MessageColor color = MessageColor::System; if (tc && getSettings()->colorUsernames) { if (auto userColor = tc->getUserColor(user.userLogin); userColor.isValid()) { color = MessageColor(userColor); } } builder ->emplace(user.userName, MessageElementFlag::BoldUsername, color, FontStyle::ChatMediumBold) ->setLink({Link::UserInfo, user.userLogin}) ->setTrailingSpace(false); builder ->emplace(user.userName, MessageElementFlag::NonBoldUsername, color) ->setLink({Link::UserInfo, user.userLogin}) ->setTrailingSpace(false); } builder->message().messageText = text; builder->message().searchText = text; } MessagePtr TwitchMessageBuilder::buildHypeChatMessage( Communi::IrcPrivateMessage *message) { auto levelID = message->tag(u"pinned-chat-paid-level"_s).toString(); auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString(); bool okAmount = false; auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount); bool okExponent = false; auto exponent = message->tag(u"pinned-chat-paid-exponent"_s).toInt(&okExponent); if (!okAmount || !okExponent || currency.isEmpty()) { return {}; } // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino. QString subtitle; auto levelIt = HYPE_CHAT_PAID_LEVEL.find(levelID); if (levelIt != HYPE_CHAT_PAID_LEVEL.end()) { const auto &level = levelIt->second; subtitle = u"Level %1 Hype Chat (%2) "_s.arg(level.numeric) .arg(formatTime(level.duration)); } else { subtitle = u"Hype Chat "_s; } // actualAmount = amount * 10^(-exponent) double actualAmount = std::pow(10.0, double(-exponent)) * double(amount); subtitle += QLocale::system().toCurrencyString(actualAmount, currency); MessageBuilder builder(systemMessage, parseTagString(subtitle), calculateMessageTime(message).time()); builder->flags.set(MessageFlag::ElevatedMessage); return builder.release(); } void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); } void TwitchMessageBuilder::setParent(MessagePtr parent) { this->parent_ = std::move(parent); } void TwitchMessageBuilder::setMessageOffset(int offset) { this->messageOffset_ = offset; } } // namespace chatterino