refactor: load Twitch emotes from Helix (#5239)

This commit is contained in:
nerix
2024-09-01 11:22:54 +02:00
committed by GitHub
parent 03b0e4881f
commit 820aa12af6
40 changed files with 1251 additions and 528 deletions
-22
View File
@@ -31,28 +31,6 @@ void IvrApi::getSubage(QString userName, QString channelName,
.execute();
}
void IvrApi::getBulkEmoteSets(QString emoteSetList,
ResultCallback<QJsonArray> successCallback,
IvrFailureCallback failureCallback)
{
QUrlQuery urlQuery;
urlQuery.addQueryItem("set_id", emoteSetList);
this->makeRequest("twitch/emotes/sets", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) {
auto root = result.parseJsonArray();
successCallback(root);
})
.onError([failureCallback](auto result) {
qCWarning(chatterinoIvr)
<< "Failed IVR API Call!" << result.formatError()
<< QString(result.getData());
failureCallback();
})
.execute();
}
NetworkRequest IvrApi::makeRequest(QString url, QUrlQuery urlQuery)
{
assert(!url.startsWith("/"));
-45
View File
@@ -1,7 +1,6 @@
#pragma once
#include "common/network/NetworkRequest.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include <QJsonArray>
#include <QJsonObject>
@@ -32,45 +31,6 @@ struct IvrSubage {
}
};
struct IvrEmoteSet {
const QString setId;
const QString displayName;
const QString login;
const QString channelId;
const QString tier;
const QJsonArray emotes;
IvrEmoteSet(const QJsonObject &root)
: setId(root.value("setID").toString())
, displayName(root.value("channelName").toString())
, login(root.value("channelLogin").toString())
, channelId(root.value("channelID").toString())
, tier(root.value("tier").toString())
, emotes(root.value("emoteList").toArray())
{
}
};
struct IvrEmote {
const QString code;
const QString id;
const QString setId;
const QString url;
const QString emoteType;
const QString imageType;
explicit IvrEmote(const QJsonObject &root)
: code(root.value("code").toString())
, id(root.value("id").toString())
, setId(root.value("setID").toString())
, url(TWITCH_EMOTE_TEMPLATE.arg(this->id, u"3.0"))
, emoteType(root.value("type").toString())
, imageType(root.value("assetType").toString())
{
}
};
class IvrApi final
{
public:
@@ -79,11 +39,6 @@ public:
ResultCallback<IvrSubage> resultCallback,
IvrFailureCallback failureCallback);
// https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_emotes_sets
void getBulkEmoteSets(QString emoteSetList,
ResultCallback<QJsonArray> successCallback,
IvrFailureCallback failureCallback);
static void initialize();
IvrApi() = default;
@@ -870,17 +870,6 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message)
void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message)
{
auto currentUser = getApp()->getAccounts()->twitch.getCurrent();
// set received emote-sets, used in TwitchAccount::loadUserstateEmotes
bool emoteSetsChanged = currentUser->setUserstateEmoteSets(
message->tag("emote-sets").toString().split(","));
if (emoteSetsChanged)
{
currentUser->loadUserstateEmotes();
}
QString channelName;
if (!trimChannelName(message->parameter(0), channelName))
{
@@ -918,24 +907,6 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message)
}
}
// This will emit only once and right after user logs in to IRC - reset emote data and reload emotes
void IrcMessageHandler::handleGlobalUserStateMessage(
Communi::IrcMessage *message)
{
auto currentUser = getApp()->getAccounts()->twitch.getCurrent();
// set received emote-sets, this time used to initially load emotes
// NOTE: this should always return true unless we reconnect
auto emoteSetsChanged = currentUser->setUserstateEmoteSets(
message->tag("emote-sets").toString().split(","));
// We should always attempt to reload emotes even on reconnections where
// emoteSetsChanged, since we want to trigger emote reloads when
// "currentUserChanged" signal is emitted
qCDebug(chatterinoTwitch) << emoteSetsChanged << message->toData();
currentUser->loadEmotes();
}
void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage)
{
MessageParseArgs args;
@@ -44,7 +44,6 @@ public:
void handleClearChatMessage(Communi::IrcMessage *message);
void handleClearMessageMessage(Communi::IrcMessage *message);
void handleUserStateMessage(Communi::IrcMessage *message);
void handleGlobalUserStateMessage(Communi::IrcMessage *message);
void handleWhisperMessage(Communi::IrcMessage *ircMessage);
void handleUserNoticeMessage(Communi::IrcMessage *message,
+147 -208
View File
@@ -3,26 +3,33 @@
#include "Application.hpp"
#include "common/Channel.hpp"
#include "common/Env.hpp"
#include "common/Literals.hpp"
#include "common/network/NetworkResult.hpp"
#include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "debug/AssertInGuiThread.hpp"
#include "messages/Emote.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/IvrApi.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "singletons/Emotes.hpp"
#include "util/CancellationToken.hpp"
#include "util/Helpers.hpp"
#include "util/QStringHash.hpp"
#include "util/RapidjsonHelpers.hpp"
#include <boost/unordered/unordered_flat_map.hpp>
#include <QStringBuilder>
#include <QThread>
namespace chatterino {
using namespace literals;
TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken,
const QString &oauthClient, const QString &userID)
: Account(ProviderId::Twitch)
@@ -31,9 +38,13 @@ TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken,
, userName_(username)
, userId_(userID)
, isAnon_(username == ANONYMOUS_USERNAME)
, emoteSets_(std::make_shared<TwitchEmoteSetMap>())
, emotes_(std::make_shared<EmoteMap>())
{
}
TwitchAccount::~TwitchAccount() = default;
QString TwitchAccount::toString() const
{
return this->getUserName();
@@ -175,214 +186,6 @@ const std::unordered_set<QString> &TwitchAccount::blockedUserIds() const
return this->ignoresUserIds_;
}
void TwitchAccount::loadEmotes(std::weak_ptr<Channel> weakChannel)
{
qCDebug(chatterinoTwitch)
<< "Loading Twitch emotes for user" << this->getUserName();
if (this->getOAuthClient().isEmpty() || this->getOAuthToken().isEmpty())
{
qCDebug(chatterinoTwitch)
<< "Aborted loadEmotes due to missing Client ID and/or OAuth token";
return;
}
{
auto emoteData = this->emotes_.access();
emoteData->emoteSets.clear();
emoteData->emotes.clear();
qCDebug(chatterinoTwitch) << "Cleared emotes!";
}
this->loadUserstateEmotes(weakChannel);
}
bool TwitchAccount::setUserstateEmoteSets(QStringList newEmoteSets)
{
newEmoteSets.sort();
if (this->userstateEmoteSets_ == newEmoteSets)
{
// Nothing has changed
return false;
}
this->userstateEmoteSets_ = newEmoteSets;
return true;
}
void TwitchAccount::loadUserstateEmotes(std::weak_ptr<Channel> weakChannel)
{
if (this->userstateEmoteSets_.isEmpty())
{
return;
}
QStringList newEmoteSetKeys, existingEmoteSetKeys;
auto emoteData = this->emotes_.access();
auto userEmoteSets = emoteData->emoteSets;
// get list of already fetched emote sets
for (const auto &userEmoteSet : userEmoteSets)
{
existingEmoteSetKeys.push_back(userEmoteSet->key);
}
// filter out emote sets from userstate message, which are not in fetched emote set list
for (const auto &emoteSetKey : this->userstateEmoteSets_)
{
if (!existingEmoteSetKeys.contains(emoteSetKey))
{
newEmoteSetKeys.push_back(emoteSetKey);
}
}
// return if there are no new emote sets
if (newEmoteSetKeys.isEmpty())
{
return;
}
// requesting emotes
auto batches = splitListIntoBatches(newEmoteSetKeys);
for (int i = 0; i < batches.size(); i++)
{
qCDebug(chatterinoTwitch)
<< QString(
"Loading %1 emotesets from IVR; batch %2/%3 (%4 sets): %5")
.arg(newEmoteSetKeys.size())
.arg(i + 1)
.arg(batches.size())
.arg(batches.at(i).size())
.arg(batches.at(i).join(","));
getIvr()->getBulkEmoteSets(
batches.at(i).join(","),
[this, weakChannel](QJsonArray emoteSetArray) {
auto emoteData = this->emotes_.access();
auto localEmoteData = this->localEmotes_.access();
std::unordered_set<QString> subscriberChannelIDs;
std::vector<IvrEmoteSet> ivrEmoteSets;
ivrEmoteSets.reserve(emoteSetArray.size());
for (auto emoteSet : emoteSetArray)
{
IvrEmoteSet ivrEmoteSet(emoteSet.toObject());
if (!ivrEmoteSet.tier.isNull())
{
subscriberChannelIDs.insert(ivrEmoteSet.channelId);
}
ivrEmoteSets.emplace_back(ivrEmoteSet);
}
for (const auto &emoteSet : emoteData->emoteSets)
{
if (emoteSet->subscriber)
{
subscriberChannelIDs.insert(emoteSet->channelID);
}
}
for (const auto &ivrEmoteSet : ivrEmoteSets)
{
auto emoteSet = std::make_shared<EmoteSet>();
QString setKey = ivrEmoteSet.setId;
emoteSet->key = setKey;
// check if the emoteset is already in emoteData
auto isAlreadyFetched =
std::find_if(emoteData->emoteSets.begin(),
emoteData->emoteSets.end(),
[setKey](std::shared_ptr<EmoteSet> set) {
return (set->key == setKey);
});
if (isAlreadyFetched != emoteData->emoteSets.end())
{
continue;
}
emoteSet->channelID = ivrEmoteSet.channelId;
emoteSet->channelName = ivrEmoteSet.login;
emoteSet->text = ivrEmoteSet.displayName;
emoteSet->subscriber = !ivrEmoteSet.tier.isNull();
// NOTE: If a user does not have a subscriber emote set, but a follower emote set, this logic will be wrong
// However, that's not a realistic problem.
bool haveSubscriberSetForChannel =
subscriberChannelIDs.contains(ivrEmoteSet.channelId);
for (const auto &emoteObj : ivrEmoteSet.emotes)
{
IvrEmote ivrEmote(emoteObj.toObject());
auto id = EmoteId{ivrEmote.id};
auto code = EmoteName{
TwitchEmotes::cleanUpEmoteCode(ivrEmote.code)};
emoteSet->emotes.push_back(TwitchEmote{id, code});
auto emote = getApp()
->getEmotes()
->getTwitchEmotes()
->getOrCreateEmote(id, code);
// Follower emotes can be only used in their origin channel
// unless the user is subscribed, then they can be used anywhere.
if (ivrEmote.emoteType == "FOLLOWER" &&
!haveSubscriberSetForChannel)
{
emoteSet->local = true;
// EmoteMap for target channel wasn't initialized yet, doing it now
if (localEmoteData->find(ivrEmoteSet.channelId) ==
localEmoteData->end())
{
localEmoteData->emplace(ivrEmoteSet.channelId,
EmoteMap());
}
localEmoteData->at(ivrEmoteSet.channelId)
.emplace(code, emote);
}
else
{
emoteData->emotes.emplace(code, emote);
}
}
std::sort(emoteSet->emotes.begin(), emoteSet->emotes.end(),
[](const TwitchEmote &l, const TwitchEmote &r) {
return l.name.string < r.name.string;
});
emoteData->emoteSets.emplace_back(emoteSet);
}
if (auto channel = weakChannel.lock(); channel != nullptr)
{
channel->addSystemMessage(
"Twitch subscriber emotes reloaded.");
}
},
[] {
// fetching emotes failed, ivr API might be down
});
};
}
SharedAccessGuard<const TwitchAccount::TwitchAccountEmoteData>
TwitchAccount::accessEmotes() const
{
return this->emotes_.accessConst();
}
SharedAccessGuard<const std::unordered_map<QString, EmoteMap>>
TwitchAccount::accessLocalEmotes() const
{
return this->localEmotes_.accessConst();
}
// AutoModActions
void TwitchAccount::autoModAllow(const QString msgID, ChannelPtr channel)
{
@@ -515,4 +318,140 @@ void TwitchAccount::loadSeventvUserID()
});
}
bool TwitchAccount::hasEmoteSet(const EmoteSetId &id) const
{
auto emotes = this->emoteSets_.accessConst();
return emotes->get()->contains(id);
}
SharedAccessGuard<std::shared_ptr<const TwitchEmoteSetMap>>
TwitchAccount::accessEmoteSets() const
{
return this->emoteSets_.accessConst();
}
SharedAccessGuard<std::shared_ptr<const EmoteMap>> TwitchAccount::accessEmotes()
const
{
return this->emotes_.accessConst();
}
std::optional<EmotePtr> TwitchAccount::twitchEmote(const EmoteName &name) const
{
auto emotes = this->emotes_.accessConst();
auto it = (*emotes)->find(name);
if (it != (*emotes)->end())
{
return it->second;
}
return std::nullopt;
}
void TwitchAccount::reloadEmotes(void *caller)
{
if (this->isAnon() || getApp()->isTest())
{
return;
}
CancellationToken token(false);
this->emoteToken_ = token;
auto sets = std::make_shared<TwitchEmoteSetMap>();
auto emoteMap = std::make_shared<EmoteMap>();
auto nCalls = std::make_shared<size_t>();
auto *twitchEmotes = getApp()->getEmotes()->getTwitchEmotes();
auto *twitchUsers = getApp()->getTwitchUsers();
auto addEmote = [sets, emoteMap, twitchEmotes,
twitchUsers](const HelixChannelEmote &emote) {
EmoteId id{emote.id};
EmoteName name{emote.name};
auto meta = getTwitchEmoteSetMeta(emote);
auto emotePtr = twitchEmotes->getOrCreateEmote(id, name);
if (!emoteMap->try_emplace(name, emotePtr).second)
{
// if the emote already exists, we don't want to add it to a set as
// those are assumed to be disjoint
return;
}
auto set = sets->find(EmoteSetId{meta.setID});
if (set == sets->end())
{
auto owner = [&]() {
if (meta.isSubLike)
{
return twitchUsers->resolveID({emote.ownerID});
}
return std::make_shared<TwitchUser>(TwitchUser{
.id = u"[x-c2-global-owner]"_s,
.name = {},
.displayName = {},
});
}();
set = sets->emplace(EmoteSetId{meta.setID},
TwitchEmoteSet{
.owner = owner,
.emotes = {},
.isBits = meta.isBits,
.isSubLike = meta.isSubLike,
})
.first;
}
set->second.emotes.emplace_back(std::move(emotePtr));
};
auto userID = this->getUserId();
qDebug(chatterinoTwitch).nospace()
<< "Loading Twitch emotes - userID: " << userID
<< ", broadcasterID: none, manualRefresh: " << (caller != nullptr);
getHelix()->getUserEmotes(
this->getUserId(), {},
[this, caller, emoteMap, sets, addEmote, nCalls](
const auto &emotes, const auto &state) mutable {
assert(emoteMap && sets);
(*nCalls)++;
qDebug(chatterinoTwitch).nospace()
<< "Got " << emotes.size() << " more emote(s)";
emoteMap->reserve(emoteMap->size() + emotes.size());
for (const auto &emote : emotes)
{
addEmote(emote);
}
if (state.done)
{
qDebug(chatterinoTwitch).nospace()
<< "Loaded " << emoteMap->size() << " Twitch emotes ("
<< *nCalls << " requests)";
for (auto &[id, set] : *sets)
{
std::ranges::sort(
set.emotes, [](const auto &l, const auto &r) {
return l->name.string < r->name.string;
});
}
*this->emotes_.access() = std::move(emoteMap);
*this->emoteSets_.access() = std::move(sets);
getApp()->getAccounts()->twitch.emotesReloaded.invoke(caller,
{});
}
},
[caller](const auto &error) {
qDebug(chatterinoTwitch)
<< "Failed to load Twitch emotes:" << error;
getApp()->getAccounts()->twitch.emotesReloaded.invoke(
caller, makeUnexpected(error));
},
std::move(token));
}
} // namespace chatterino
+31 -39
View File
@@ -1,14 +1,16 @@
#pragma once
#include "common/Aliases.hpp"
#include "common/Atomic.hpp"
#include "common/UniqueAccess.hpp"
#include "controllers/accounts/Account.hpp"
#include "messages/Emote.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "providers/twitch/TwitchUser.hpp"
#include "util/CancellationToken.hpp"
#include "util/QStringHash.hpp"
#include <boost/unordered/unordered_flat_map_fwd.hpp>
#include <pajlada/signals.hpp>
#include <QColor>
#include <QElapsedTimer>
#include <QObject>
@@ -18,7 +20,6 @@
#include <functional>
#include <mutex>
#include <unordered_set>
#include <vector>
namespace chatterino {
@@ -28,31 +29,13 @@ using ChannelPtr = std::shared_ptr<Channel>;
class TwitchAccount : public Account
{
public:
struct TwitchEmote {
EmoteId id;
EmoteName name;
};
struct EmoteSet {
QString key;
QString channelName;
QString channelID;
QString text;
bool subscriber{false};
bool local{false};
std::vector<TwitchEmote> emotes;
};
struct TwitchAccountEmoteData {
std::vector<std::shared_ptr<EmoteSet>> emoteSets;
// this EmoteMap should contain all emotes available globally
// excluding locally available emotes, such as follower ones
EmoteMap emotes;
};
TwitchAccount(const QString &username, const QString &oauthToken_,
const QString &oauthClient_, const QString &_userID);
~TwitchAccount() override;
TwitchAccount(const TwitchAccount &) = delete;
TwitchAccount(TwitchAccount &&) = delete;
TwitchAccount &operator=(const TwitchAccount &) = delete;
TwitchAccount &operator=(TwitchAccount &&) = delete;
QString toString() const override;
@@ -91,23 +74,32 @@ public:
[[nodiscard]] const std::unordered_set<TwitchUser> &blocks() const;
[[nodiscard]] const std::unordered_set<QString> &blockedUserIds() const;
void loadEmotes(std::weak_ptr<Channel> weakChannel = {});
// loadUserstateEmotes loads emote sets that are part of the USERSTATE emote-sets key
// this function makes sure not to load emote sets that have already been loaded
void loadUserstateEmotes(std::weak_ptr<Channel> weakChannel = {});
// setUserStateEmoteSets sets the emote sets that were parsed from the USERSTATE emote-sets key
// Returns true if the newly inserted emote sets differ from the ones previously saved
[[nodiscard]] bool setUserstateEmoteSets(QStringList newEmoteSets);
SharedAccessGuard<const TwitchAccountEmoteData> accessEmotes() const;
SharedAccessGuard<const std::unordered_map<QString, EmoteMap>>
accessLocalEmotes() const;
// Automod actions
void autoModAllow(const QString msgID, ChannelPtr channel);
void autoModDeny(const QString msgID, ChannelPtr channel);
void loadSeventvUserID();
/// Returns true if the account has access to the given emote set
bool hasEmoteSet(const EmoteSetId &id) const;
/// Return a map of emote sets the account has access to
///
/// Key being the emote set ID, and contents being information about the emote set
/// and the emotes contained in the emote set
SharedAccessGuard<std::shared_ptr<const TwitchEmoteSetMap>>
accessEmoteSets() const;
/// Return a map of emotes the account has access to
SharedAccessGuard<std::shared_ptr<const EmoteMap>> accessEmotes() const;
/// Return the emote by emote name if the account has access to the emote
std::optional<EmotePtr> twitchEmote(const EmoteName &name) const;
/// Once emotes are reloaded, TwitchAccountManager::emotesReloaded is
/// invoked with @a caller and an optional error.
void reloadEmotes(void *caller = nullptr);
private:
QString oauthClient_;
QString oauthToken_;
@@ -122,9 +114,9 @@ private:
std::unordered_set<TwitchUser> ignores_;
std::unordered_set<QString> ignoresUserIds_;
// std::map<UserId, TwitchAccountEmoteData> emotes;
UniqueAccess<TwitchAccountEmoteData> emotes_;
UniqueAccess<std::unordered_map<QString, EmoteMap>> localEmotes_;
ScopedCancellationToken emoteToken_;
UniqueAccess<std::shared_ptr<const TwitchEmoteSetMap>> emoteSets_;
UniqueAccess<std::shared_ptr<const EmoteMap>> emotes_;
QString seventvUserID_;
};
@@ -162,6 +162,7 @@ void TwitchAccountManager::load()
}
this->currentUserChanged();
this->currentUser_->reloadEmotes();
});
}
@@ -2,6 +2,7 @@
#include "common/ChatterinoSetting.hpp"
#include "common/SignalVector.hpp"
#include "util/Expected.hpp"
#include "util/QStringHash.hpp"
#include "util/RapidJsonSerializeQString.hpp"
@@ -58,6 +59,10 @@ public:
SignalVector<std::shared_ptr<TwitchAccount>> accounts;
/// The signal is invoked with (caller, error) where caller is the argument
/// passed to reloadEmotes() and error.
pajlada::Signals::Signal<void *, ExpectedStr<void>> emotesReloaded;
private:
enum class AddUserResponse {
UserAlreadyExists,
+133
View File
@@ -2,6 +2,8 @@
#include "Application.hpp"
#include "common/Common.hpp"
#include "common/Env.hpp"
#include "common/Literals.hpp"
#include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp"
#include "common/QLogging.hpp"
@@ -32,6 +34,7 @@
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Settings.hpp"
#include "singletons/StreamerMode.hpp"
@@ -47,11 +50,15 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QStringBuilder>
#include <QThread>
#include <QTimer>
#include <rapidjson/document.h>
namespace chatterino {
using namespace literals;
namespace {
#if QT_VERSION < QT_VERSION_CHECK(6, 1, 0)
const QString MAGIC_MESSAGE_SUFFIX = QString((const char *)u8" \U000E0000");
@@ -84,6 +91,7 @@ TwitchChannel::TwitchChannel(const QString &name)
, subscriptionUrl_("https://www.twitch.tv/subs/" + name)
, channelUrl_("https://twitch.tv/" + name)
, popoutPlayerUrl_(TWITCH_PLAYER_URL.arg(name))
, localTwitchEmotes_(std::make_shared<EmoteMap>())
, bttvEmotes_(std::make_shared<EmoteMap>())
, ffzEmotes_(std::make_shared<EmoteMap>())
, seventvEmotes_(std::make_shared<EmoteMap>())
@@ -94,6 +102,7 @@ TwitchChannel::TwitchChannel(const QString &name)
getApp()->getAccounts()->twitch.currentUserChanged.connect([this] {
this->setMod(false);
this->refreshPubSub();
this->refreshTwitchChannelEmotes(false);
}));
this->refreshPubSub();
@@ -133,6 +142,36 @@ TwitchChannel::TwitchChannel(const QString &name)
});
this->threadClearTimer_.start(5 * 60 * 1000);
this->signalHolder_.managedConnect(
getApp()->getAccounts()->twitch.emotesReloaded,
[this](auto *caller, const auto &result) {
if (result)
{
// emotes were reloaded - clear follower emotes if the user is
// now subscribed to the streamer
if (!this->localTwitchEmotes_.get()->empty() &&
getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet(
EmoteSetId{this->localTwitchEmoteSetID_.get()}))
{
this->localTwitchEmotes_.set(std::make_shared<EmoteMap>());
}
if (caller == this)
{
this->addSystemMessage(
"Twitch subscriber emotes reloaded.");
}
return;
}
if (caller == this || caller == nullptr)
{
this->addSystemMessage(
u"Failed to load Twitch subscriber emotes: " %
result.error());
}
});
// debugging
#if 0
for (int i = 0; i < 1000; i++) {
@@ -194,6 +233,82 @@ void TwitchChannel::setLocalizedName(const QString &name)
this->nameOptions.localizedName = name;
}
void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh)
{
if (getApp()->isTest())
{
return;
}
// Twitch's 'Get User Emotes' doesn't assigns a different set-ID to follower
// emotes compared to subscriber emotes.
QString setID = TWITCH_SUB_EMOTE_SET_PREFIX % this->roomId();
this->localTwitchEmoteSetID_.set(setID);
if (getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet(
EmoteSetId{setID}))
{
this->localTwitchEmotes_.set(std::make_shared<EmoteMap>());
return;
}
auto makeEmotes = [](const auto &emotes) {
EmoteMap map;
for (const auto &emote : emotes)
{
if (emote.type != u"follower")
{
continue;
}
map.emplace(
EmoteName{emote.name},
getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
EmoteId{emote.id}, EmoteName{emote.name}));
}
return map;
};
getHelix()->getFollowedChannel(
getApp()->getAccounts()->twitch.getCurrent()->getUserId(),
this->roomId(),
[weak{this->weak_from_this()}, makeEmotes](const auto &chan) {
auto self = std::dynamic_pointer_cast<TwitchChannel>(weak.lock());
if (!self || !chan)
{
return;
}
getHelix()->getChannelEmotes(
self->roomId(),
[weak, makeEmotes](const auto &emotes) {
auto self =
std::dynamic_pointer_cast<TwitchChannel>(weak.lock());
if (!self)
{
return;
}
self->localTwitchEmotes_.set(
std::make_shared<EmoteMap>(makeEmotes(emotes)));
},
[weak] {
auto self = weak.lock();
if (!self)
{
return;
}
self->addSystemMessage("Failed to load follower emotes.");
});
},
[](const auto &error) {
qCWarning(chatterinoTwitch)
<< "Failed to get following status:" << error;
});
if (manualRefresh)
{
getApp()->getAccounts()->twitch.getCurrent()->reloadEmotes(this);
}
}
void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh)
{
if (!Settings::instance().enableBTTVChannelEmotes)
@@ -536,6 +651,7 @@ void TwitchChannel::roomIdChanged()
this->refreshPubSub();
this->refreshBadges();
this->refreshCheerEmotes();
this->refreshTwitchChannelEmotes(false);
this->refreshFFZChannelEmotes(false);
this->refreshBTTVChannelEmotes(false);
this->refreshSevenTVChannelEmotes(false);
@@ -789,6 +905,18 @@ SharedAccessGuard<const TwitchChannel::StreamStatus>
return this->streamStatus_.accessConst();
}
std::optional<EmotePtr> TwitchChannel::twitchEmote(const EmoteName &name) const
{
auto emotes = this->localTwitchEmotes();
auto it = emotes->find(name);
if (it == emotes->end())
{
return getApp()->getAccounts()->twitch.getCurrent()->twitchEmote(name);
}
return it->second;
}
std::optional<EmotePtr> TwitchChannel::bttvEmote(const EmoteName &name) const
{
auto emotes = this->bttvEmotes_.get();
@@ -825,6 +953,11 @@ std::optional<EmotePtr> TwitchChannel::seventvEmote(const EmoteName &name) const
return it->second;
}
std::shared_ptr<const EmoteMap> TwitchChannel::localTwitchEmotes() const
{
return this->localTwitchEmotes_.get();
}
std::shared_ptr<const EmoteMap> TwitchChannel::bttvEmotes() const
{
return this->bttvEmotes_.get();
+6
View File
@@ -159,13 +159,17 @@ public:
void markConnected();
// Emotes
std::optional<EmotePtr> twitchEmote(const EmoteName &name) const;
std::optional<EmotePtr> bttvEmote(const EmoteName &name) const;
std::optional<EmotePtr> ffzEmote(const EmoteName &name) const;
std::optional<EmotePtr> seventvEmote(const EmoteName &name) const;
std::shared_ptr<const EmoteMap> localTwitchEmotes() const;
std::shared_ptr<const EmoteMap> bttvEmotes() const;
std::shared_ptr<const EmoteMap> ffzEmotes() const;
std::shared_ptr<const EmoteMap> seventvEmotes() const;
void refreshTwitchChannelEmotes(bool manualRefresh);
void refreshBTTVChannelEmotes(bool manualRefresh);
void refreshFFZChannelEmotes(bool manualRefresh);
void refreshSevenTVChannelEmotes(bool manualRefresh);
@@ -391,6 +395,8 @@ private:
protected:
void messageRemovedFromStart(const MessagePtr &msg) override;
Atomic<std::shared_ptr<const EmoteMap>> localTwitchEmotes_;
Atomic<QString> localTwitchEmoteSetID_;
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
Atomic<std::shared_ptr<const EmoteMap>> ffzEmotes_;
Atomic<std::shared_ptr<const EmoteMap>> seventvEmotes_;
+61
View File
@@ -1,10 +1,15 @@
#include "providers/twitch/TwitchEmotes.hpp"
#include "common/Literals.hpp"
#include "common/QLogging.hpp"
#include "common/UniqueAccess.hpp"
#include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "util/QStringHash.hpp"
#include <QStringBuilder>
namespace {
using namespace chatterino;
@@ -399,6 +404,22 @@ qreal getEmote3xScaleFactor(const EmoteId &id)
namespace chatterino {
using namespace literals;
QString TwitchEmoteSet::title() const
{
if (!this->owner || this->owner->name.isEmpty())
{
return "Twitch";
}
if (this->isBits)
{
return this->owner->name + " (Bits)";
}
return this->owner->name;
}
QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode)
{
auto cleanCode = dirtyEmoteCode;
@@ -453,4 +474,44 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id,
return shared;
}
TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote)
{
// follower emotes are treated as sub emotes
// A sub emote must have an owner or an emote-set id (otherwise it's a
// global like "BopBop")
bool isSub =
(emote.type == u"subscriptions" || emote.type == u"follower") &&
!(emote.ownerID.isEmpty() && emote.setID.isEmpty());
bool isBits = emote.type == u"bitstier";
bool isSubLike = isSub || isBits;
// A lot of emotes don't have their emote-set-id set, so we create a
// virtual emote set that groups emotes by the owner.
// Additionally, a lot of emote sets are small, so they're grouped together as globals.
auto actualSetID = [&]() -> QString {
if (!isSub && !isBits)
{
return u"x-c2-globals"_s;
}
if (!emote.setID.isEmpty())
{
return emote.setID;
}
if (isSub)
{
return TWITCH_SUB_EMOTE_SET_PREFIX % emote.ownerID;
}
// isBits
return TWITCH_BIT_EMOTE_SET_PREFIX % emote.ownerID;
}();
return {
.setID = actualSetID,
.isBits = isBits,
.isSubLike = isSubLike,
};
}
} // namespace chatterino
+41
View File
@@ -2,7 +2,9 @@
#include "common/Aliases.hpp"
#include "common/UniqueAccess.hpp"
#include "providers/twitch/TwitchUser.hpp"
#include <boost/unordered/unordered_flat_map_fwd.hpp>
#include <QColor>
#include <QRegularExpression>
#include <QString>
@@ -36,6 +38,45 @@ struct CheerEmoteSet {
std::vector<CheerEmote> cheerEmotes;
};
struct TwitchEmoteSet {
/// @brief The owner of this set
///
/// This owner might not be resolved yet
std::shared_ptr<TwitchUser> owner;
std::vector<EmotePtr> emotes;
/// If this is a bitstier emote set
bool isBits = false;
/// @brief If this emote set is a subscriber or similar emote set
///
/// This includes sub and bit emotes
bool isSubLike = false;
/// @brief The title of this set
///
/// We generate this based on the emote set's flags & owner
QString title() const;
};
using TwitchEmoteSetMap = boost::unordered_flat_map<EmoteSetId, TwitchEmoteSet>;
struct HelixChannelEmote;
constexpr QStringView TWITCH_SUB_EMOTE_SET_PREFIX = u"x-c2-s-";
constexpr QStringView TWITCH_BIT_EMOTE_SET_PREFIX = u"x-c2-b-";
struct TwitchEmoteSetMeta {
QString setID;
/// See TwitchEmoteSet::isBits
bool isBits = false;
/// See TwitchEmoteSet::isSubLike
bool isSubLike = false;
};
TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote);
class ITwitchEmotes
{
public:
-4
View File
@@ -853,10 +853,6 @@ void TwitchIrcServer::readConnectionMessageReceived(
this->markChannelsConnected();
this->connect();
}
else if (command == "GLOBALUSERSTATE")
{
handler.handleGlobalUserStateMessage(message);
}
}
void TwitchIrcServer::writeConnectionMessageReceived(
+9 -1
View File
@@ -1,7 +1,7 @@
#include "providers/twitch/TwitchUser.hpp"
#include "debug/AssertInGuiThread.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "util/RapidjsonHelpers.hpp"
namespace chatterino {
@@ -12,4 +12,12 @@ void TwitchUser::fromHelixBlock(const HelixBlock &ignore)
this->displayName = ignore.displayName;
}
void TwitchUser::update(const HelixUser &user) const
{
assertInGuiThread();
assert(this->id == user.id);
this->name = user.login;
this->displayName = user.displayName;
}
} // namespace chatterino
+3
View File
@@ -12,6 +12,7 @@
namespace chatterino {
struct HelixBlock;
struct HelixUser;
struct TwitchUser {
QString id;
@@ -26,6 +27,8 @@ struct TwitchUser {
this->displayName = other.displayName;
}
void update(const HelixUser &user) const;
void fromHelixBlock(const HelixBlock &ignore);
bool operator<(const TwitchUser &rhs) const
+147
View File
@@ -0,0 +1,147 @@
#include "providers/twitch/TwitchUsers.hpp"
#include "common/QLogging.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchUser.hpp"
#include <boost/unordered/unordered_flat_map.hpp>
#include <QStringList>
#include <QTimer>
namespace {
auto withSelf(auto *ptr, auto cb)
{
return [weak{ptr->weak_from_this()}, cb = std::move(cb)](auto &&...args) {
auto self = weak.lock();
if (!self)
{
return;
}
cb(std::move(self), std::forward<decltype(args)>(args)...);
};
}
} // namespace
namespace chatterino {
class TwitchUsersPrivate
: public std::enable_shared_from_this<TwitchUsersPrivate>
{
public:
TwitchUsersPrivate();
private:
boost::unordered_flat_map<UserId, std::shared_ptr<TwitchUser>> cache;
QStringList unresolved;
QTimer nextBatchTimer;
bool isResolving = false;
std::shared_ptr<TwitchUser> makeUnresolved(const UserId &id);
void makeNextRequest();
void updateUsers(const std::vector<HelixUser> &users);
friend TwitchUsers;
};
TwitchUsers::TwitchUsers()
: private_(new TwitchUsersPrivate)
{
}
TwitchUsers::~TwitchUsers() = default;
std::shared_ptr<TwitchUser> TwitchUsers::resolveID(const UserId &id)
{
auto cached = this->private_->cache.find(id);
if (cached != this->private_->cache.end())
{
return cached->second;
}
return this->private_->makeUnresolved(id);
}
TwitchUsersPrivate::TwitchUsersPrivate()
{
this->nextBatchTimer.setSingleShot(true);
// Wait for multiple request batches to come in before making a request
this->nextBatchTimer.setInterval(250);
QObject::connect(&this->nextBatchTimer, &QTimer::timeout, [this] {
this->makeNextRequest();
});
}
std::shared_ptr<TwitchUser> TwitchUsersPrivate::makeUnresolved(const UserId &id)
{
// assumption: Cache entry is empty so neither a shared pointer was created
// nor an entry in the unresolved list was added.
auto ptr = this->cache
.emplace(id, std::make_shared<TwitchUser>(TwitchUser{
.id = id.string,
.name = {},
.displayName = {},
}))
.first->second;
if (id.string.isEmpty())
{
return ptr;
}
this->unresolved.append(id.string);
if (!this->isResolving && !this->nextBatchTimer.isActive())
{
this->nextBatchTimer.start();
}
return ptr;
}
void TwitchUsersPrivate::makeNextRequest()
{
if (this->unresolved.empty())
{
return;
}
if (this->isResolving)
{
qCWarning(chatterinoTwitch) << "Tried to start request while resolving";
return;
}
this->isResolving = true;
auto ids = this->unresolved.mid(
0, std::min<qsizetype>(this->unresolved.size(), 100));
this->unresolved = this->unresolved.mid(ids.length());
getHelix()->fetchUsers(ids, {},
withSelf(this,
[](auto self, const auto &users) {
self->updateUsers(users);
self->isResolving = false;
self->makeNextRequest();
}),
withSelf(this, [](auto self) {
qCWarning(chatterinoTwitch)
<< "Failed to load users";
self->isResolving = false;
self->makeNextRequest();
}));
}
void TwitchUsersPrivate::updateUsers(const std::vector<HelixUser> &users)
{
for (const auto &user : users)
{
auto cached = this->cache.find(UserId{user.id});
if (cached == this->cache.end())
{
qCWarning(chatterinoTwitch) << "Couldn't find user" << user.login
<< "with id" << user.id << "in cache";
continue;
}
cached->second->update(user);
}
}
} // namespace chatterino
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "common/Aliases.hpp"
#include <memory>
namespace chatterino {
struct TwitchUser;
class TwitchChannel;
class ITwitchUsers
{
public:
ITwitchUsers() = default;
virtual ~ITwitchUsers() = default;
ITwitchUsers(const ITwitchUsers &) = delete;
ITwitchUsers(ITwitchUsers &&) = delete;
ITwitchUsers &operator=(const ITwitchUsers &) = delete;
ITwitchUsers &operator=(ITwitchUsers &&) = delete;
/// @brief Resolve a TwitchUser by their ID
///
/// Users are cached. If the user wasn't resolved yet, a request will be
/// scheduled. The returned shared pointer must only be used on the GUI
/// thread as it will be updated from there.
///
/// @returns A shared reference to the TwitchUser. The `name` and
/// `displayName` might be empty if the user wasn't resolved yet or
/// they don't exist.
virtual std::shared_ptr<TwitchUser> resolveID(const UserId &id) = 0;
};
class TwitchUsersPrivate;
class TwitchUsers : public ITwitchUsers
{
public:
TwitchUsers();
~TwitchUsers() override;
TwitchUsers(const TwitchUsers &) = delete;
TwitchUsers(TwitchUsers &&) = delete;
TwitchUsers &operator=(const TwitchUsers &) = delete;
TwitchUsers &operator=(TwitchUsers &&) = delete;
/// @see ITwitchUsers::resolveID()
std::shared_ptr<TwitchUser> resolveID(const UserId &id) override;
private:
// Using a shared_ptr to pass to network callbacks
std::shared_ptr<TwitchUsersPrivate> private_;
friend TwitchUsersPrivate;
};
} // namespace chatterino
+133 -8
View File
@@ -540,7 +540,8 @@ void Helix::loadBlocks(QString userId,
size_t receivedItems = 0;
this->paginate(
u"users/blocks"_s, query,
[pageCallback, receivedItems](const QJsonObject &json) mutable {
[pageCallback, receivedItems](const QJsonObject &json,
const auto & /*state*/) mutable {
const auto data = json["data"_L1].toArray();
if (data.isEmpty())
@@ -3063,6 +3064,123 @@ void Helix::sendChatMessage(
.execute();
}
void Helix::getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback, CancellationToken &&token)
{
QUrlQuery query{{u"user_id"_s, userID}};
if (!broadcasterID.isEmpty())
{
query.addQueryItem(u"broadcaster_id"_s, broadcasterID);
}
this->paginate(
u"chat/emotes/user"_s, query,
[pageCallback](const QJsonObject &json, const auto &state) mutable {
const auto data = json["data"_L1].toArray();
if (data.isEmpty())
{
return false;
}
std::vector<HelixChannelEmote> emotes;
emotes.reserve(data.count());
for (const auto &emote : data)
{
emotes.emplace_back(emote.toObject());
}
pageCallback(emotes, state);
return true;
},
[failureCallback](const NetworkResult &result) {
if (!result.status())
{
failureCallback(result.formatError());
return;
}
const auto obj = result.parseJson();
auto message = obj["message"].toString();
switch (*result.status())
{
case 401: {
if (message.startsWith("Missing scope",
Qt::CaseInsensitive))
{
failureCallback("Missing required scope. Re-login with "
"your account and try again.");
break;
}
[[fallthrough]];
}
default: {
qCWarning(chatterinoTwitch)
<< "Helix get user emotes, unhandled error data:"
<< result.formatError() << result.getData() << obj;
failureCallback(message);
}
}
},
std::move(token));
}
void Helix::getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback)
{
this->makeGet("channels/followed",
{
{u"user_id"_s, userID},
{u"broadcaster_id"_s, broadcasterID},
})
.onSuccess([successCallback](auto result) {
if (result.status() != 200)
{
qCWarning(chatterinoTwitch)
<< "Success result for getting badges was "
<< result.formatError() << "but we expected it to be 200";
}
const auto response = result.parseJson();
const auto channel = response["data"_L1].toArray().at(0);
if (channel.isObject())
{
successCallback(HelixFollowedChannel(channel.toObject()));
}
else
{
successCallback(std::nullopt);
}
})
.onError([failureCallback](const auto &result) -> void {
if (!result.status())
{
failureCallback(result.formatError());
return;
}
auto obj = result.parseJson();
auto message = obj.value("message").toString();
if (!message.isEmpty())
{
failureCallback(message);
}
else
{
failureCallback(result.formatError());
}
})
.execute();
}
NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery,
NetworkRequestType type)
{
@@ -3120,10 +3238,12 @@ NetworkRequest Helix::makePatch(const QString &url, const QUrlQuery &urlQuery)
return this->makeRequest(url, urlQuery, NetworkRequestType::Patch);
}
void Helix::paginate(const QString &url, const QUrlQuery &baseQuery,
std::function<bool(const QJsonObject &)> onPage,
std::function<void(NetworkResult)> onError,
CancellationToken &&cancellationToken)
void Helix::paginate(
const QString &url, const QUrlQuery &baseQuery,
std::function<bool(const QJsonObject &, const HelixPaginationState &state)>
onPage,
std::function<void(NetworkResult)> onError,
CancellationToken &&cancellationToken)
{
auto onSuccess =
std::make_shared<std::function<void(NetworkResult)>>(nullptr);
@@ -3143,14 +3263,19 @@ void Helix::paginate(const QString &url, const QUrlQuery &baseQuery,
}
const auto json = res.parseJson();
if (!onPage(json))
const auto pagination = json["pagination"_L1].toObject();
auto cursor = pagination["cursor"_L1].toString();
HelixPaginationState state{.done = cursor.isEmpty()};
if (!onPage(json, state))
{
// The consumer doesn't want any more pages
return;
}
auto cursor = json["pagination"_L1]["cursor"_L1].toString();
if (cursor.isEmpty())
// After done is set, onPage must never be called again
if (state.done)
{
return;
}
+62 -10
View File
@@ -266,18 +266,18 @@ struct HelixEmoteSetData {
};
struct HelixChannelEmote {
const QString emoteId;
const QString id;
const QString name;
const QString type;
const QString setId;
const QString url;
const QString setID;
const QString ownerID;
explicit HelixChannelEmote(QJsonObject jsonObject)
: emoteId(jsonObject.value("id").toString())
, name(jsonObject.value("name").toString())
, type(jsonObject.value("emote_type").toString())
, setId(jsonObject.value("emote_set_id").toString())
, url(TWITCH_EMOTE_TEMPLATE.arg(this->emoteId, u"3.0"))
explicit HelixChannelEmote(const QJsonObject &jsonObject)
: id(jsonObject["id"].toString())
, name(jsonObject["name"].toString())
, type(jsonObject["emote_type"].toString())
, setID(jsonObject["emote_set_id"].toString())
, ownerID(jsonObject["owner_id"].toString())
{
}
};
@@ -438,6 +438,22 @@ struct HelixSentMessage {
}
};
struct HelixFollowedChannel {
QString broadcasterID;
QString broadcasterLogin;
QString broadcasterName;
QDateTime followedAt;
explicit HelixFollowedChannel(const QJsonObject &jsonObject)
: broadcasterID(jsonObject["broadcaster_id"].toString())
, broadcasterLogin(jsonObject["broadcaster_login"].toString())
, broadcasterName(jsonObject["broadcaster_name"].toString())
, followedAt(QDateTime::fromString(jsonObject["followed_at"].toString(),
Qt::ISODate))
{
}
};
struct HelixSendMessageArgs {
QString broadcasterID;
QString senderID;
@@ -786,6 +802,10 @@ struct HelixError {
using HelixGetChannelBadgesError = HelixGetGlobalBadgesError;
struct HelixPaginationState {
bool done;
};
class IHelix
{
public:
@@ -1112,6 +1132,21 @@ public:
ResultCallback<HelixSentMessage> successCallback,
FailureCallback<HelixSendMessageError, QString> failureCallback) = 0;
/// https://dev.twitch.tv/docs/api/reference/#get-user-emotes
virtual void getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback,
CancellationToken &&token) = 0;
/// https://dev.twitch.tv/docs/api/reference/#get-followed-channels
/// (non paginated)
virtual void getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0;
protected:
@@ -1440,6 +1475,21 @@ public:
ResultCallback<HelixSentMessage> successCallback,
FailureCallback<HelixSendMessageError, QString> failureCallback) final;
/// https://dev.twitch.tv/docs/api/reference/#get-user-emotes
void getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback,
CancellationToken &&token) final;
/// https://dev.twitch.tv/docs/api/reference/#get-followed-channels
/// (non paginated)
void getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) final;
void update(QString clientId, QString oauthToken) final;
static void initialize();
@@ -1494,7 +1544,9 @@ private:
/// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination.
/// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop.
void paginate(const QString &url, const QUrlQuery &baseQuery,
std::function<bool(const QJsonObject &)> onPage,
std::function<bool(const QJsonObject &,
const HelixPaginationState &state)>
onPage,
std::function<void(NetworkResult)> onError,
CancellationToken &&token);