Use New 7TV Cosmetics System (#4512)

* feat(seventv): use new cosmetics system

* chore: add changelog entry

* fix: old `clang-format`

* fix: small suggestions pt1

* refactor: add 7tv api wrapper

* fix: small clang-tidy things

* fix: remove unused constants

* fix: old clangtidy

* refactor: rename

* fix: increase interval to 60s

* fix: newline

* fix: Twitch

* docs: add comment

* fix: remove v2 badges endpoint

* fix: deadlock

This is actually really sad.

* fix: remove api entry

* fix: old clang-format

* Sort functions in SeventvBadges.hpp/cpp

* Remove unused vector include

* Add comments to SeventvBadges.hpp functions

* Rename `addBadge` to `registerBadge`

* fix: cleanup eventloop

* ci(test): add timeout

---------

Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com>
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix 2023-07-29 11:49:44 +02:00 committed by GitHub
parent 8cfa5e866e
commit 33fa3e0a97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 828 additions and 189 deletions

View file

@ -87,6 +87,7 @@ jobs:
- name: Test (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
timeout-minutes: 30
run: |
docker pull kennethreitz/httpbin
docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }}

View file

@ -12,6 +12,7 @@
- Minor: Added a message for when Chatterino joins a channel (#4616)
- Minor: Add accelerators to the right click menu for messages (#4705)
- Minor: Add pin action to usercards and reply threads. (#4692)
- Minor: 7TV badges now automatically update upon changing. (#4512)
- Minor: Stream status requests are now batched. (#4713)
- Minor: Added `/c2-theme-autoreload` command to automatically reload a custom theme. This is useful for when you're developing your own theme. (#4718)
- Bugfix: Increased amount of blocked users loaded from 100 to 1,000. (#4721)

View file

@ -22,6 +22,15 @@ int main(int argc, char **argv)
::benchmark::RunSpecifiedBenchmarks();
settingsDir.remove();
// Pick up the last events from the eventloop
// Using a loop to catch events queueing other events (e.g. deletions)
for (size_t i = 0; i < 32; i++)
{
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
QApplication::exit(0);
});

View file

@ -283,8 +283,11 @@ set(SOURCE_FILES
providers/liveupdates/BasicPubSubManager.hpp
providers/liveupdates/BasicPubSubWebsocket.hpp
providers/seventv/SeventvAPI.cpp
providers/seventv/SeventvAPI.hpp
providers/seventv/SeventvBadges.cpp
providers/seventv/SeventvBadges.hpp
providers/seventv/SeventvCosmetics.hpp
providers/seventv/SeventvEmotes.cpp
providers/seventv/SeventvEmotes.hpp
providers/seventv/SeventvEventAPI.cpp

View file

@ -0,0 +1,92 @@
#include "providers/seventv/SeventvAPI.hpp"
#include "common/Literals.hpp"
#include "common/NetworkRequest.hpp"
#include "common/NetworkResult.hpp"
#include "common/Outcome.hpp"
namespace {
using namespace chatterino::literals;
const QString API_URL_USER = u"https://7tv.io/v3/users/twitch/%1"_s;
const QString API_URL_EMOTE_SET = u"https://7tv.io/v3/emote-sets/%1"_s;
const QString API_URL_PRESENCES = u"https://7tv.io/v3/users/%1/presences"_s;
} // namespace
// NOLINTBEGIN(readability-convert-member-functions-to-static)
namespace chatterino {
void SeventvAPI::getUserByTwitchID(
const QString &twitchID, SuccessCallback<const QJsonObject &> &&onSuccess,
ErrorCallback &&onError)
{
NetworkRequest(API_URL_USER.arg(twitchID), NetworkRequestType::Get)
.timeout(20000)
.onSuccess([callback = std::move(onSuccess)](
const NetworkResult &result) -> Outcome {
auto json = result.parseJson();
callback(json);
return Success;
})
.onError([callback = std::move(onError)](const NetworkResult &result) {
callback(result);
})
.execute();
}
void SeventvAPI::getEmoteSet(const QString &emoteSet,
SuccessCallback<const QJsonObject &> &&onSuccess,
ErrorCallback &&onError)
{
NetworkRequest(API_URL_EMOTE_SET.arg(emoteSet), NetworkRequestType::Get)
.timeout(25000)
.onSuccess([callback = std::move(onSuccess)](
const NetworkResult &result) -> Outcome {
auto json = result.parseJson();
callback(json);
return Success;
})
.onError([callback = std::move(onError)](const NetworkResult &result) {
callback(result);
})
.execute();
}
void SeventvAPI::updatePresence(const QString &twitchChannelID,
const QString &seventvUserID,
SuccessCallback<> &&onSuccess,
ErrorCallback &&onError)
{
QJsonObject payload{
{u"kind"_s, 1}, // UserPresenceKindChannel
{u"data"_s,
QJsonObject{
{u"id"_s, twitchChannelID},
{u"platform"_s, u"TWITCH"_s},
}},
};
NetworkRequest(API_URL_PRESENCES.arg(seventvUserID),
NetworkRequestType::Post)
.json(payload)
.timeout(10000)
.onSuccess([callback = std::move(onSuccess)](const auto &) -> Outcome {
callback();
return Success;
})
.onError([callback = std::move(onError)](const NetworkResult &result) {
callback(result);
})
.execute();
}
SeventvAPI &getSeventvAPI()
{
static SeventvAPI instance;
return instance;
}
} // namespace chatterino
// NOLINTEND(readability-convert-member-functions-to-static)

View file

@ -0,0 +1,33 @@
#pragma once
#include <functional>
class QString;
class QJsonObject;
namespace chatterino {
class NetworkResult;
class SeventvAPI
{
using ErrorCallback = std::function<void(const NetworkResult &)>;
template <typename... T>
using SuccessCallback = std::function<void(T...)>;
public:
void getUserByTwitchID(const QString &twitchID,
SuccessCallback<const QJsonObject &> &&onSuccess,
ErrorCallback &&onError);
void getEmoteSet(const QString &emoteSet,
SuccessCallback<const QJsonObject &> &&onSuccess,
ErrorCallback &&onError);
void updatePresence(const QString &twitchChannelID,
const QString &seventvUserID,
SuccessCallback<> &&onSuccess, ErrorCallback &&onError);
};
SeventvAPI &getSeventvAPI();
} // namespace chatterino

View file

@ -1,10 +1,11 @@
#include "providers/seventv/SeventvBadges.hpp"
#include "common/NetworkRequest.hpp"
#include "common/NetworkResult.hpp"
#include "common/Outcome.hpp"
#include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include <QJsonArray>
#include <QUrl>
#include <QUrlQuery>
@ -12,66 +13,68 @@
namespace chatterino {
void SeventvBadges::initialize(Settings & /*settings*/, Paths & /*paths*/)
{
this->loadSeventvBadges();
}
boost::optional<EmotePtr> SeventvBadges::getBadge(const UserId &id)
boost::optional<EmotePtr> SeventvBadges::getBadge(const UserId &id) const
{
std::shared_lock lock(this->mutex_);
auto it = this->badgeMap_.find(id.string);
if (it != this->badgeMap_.end())
{
return this->emotes_[it->second];
return it->second;
}
return boost::none;
}
void SeventvBadges::loadSeventvBadges()
void SeventvBadges::assignBadgeToUser(const QString &badgeID,
const UserId &userID)
{
// Cosmetics will work differently in v3, until this is ready
// we'll use this endpoint.
static QUrl url("https://7tv.io/v2/cosmetics");
const std::unique_lock lock(this->mutex_);
static QUrlQuery urlQuery;
// valid user_identifier values: "object_id", "twitch_id", "login"
urlQuery.addQueryItem("user_identifier", "twitch_id");
const auto badgeIt = this->knownBadges_.find(badgeID);
if (badgeIt != this->knownBadges_.end())
{
this->badgeMap_[userID.string] = badgeIt->second;
}
}
url.setQuery(urlQuery);
void SeventvBadges::clearBadgeFromUser(const QString &badgeID,
const UserId &userID)
{
const std::unique_lock lock(this->mutex_);
NetworkRequest(url)
.onSuccess([this](const NetworkResult &result) -> Outcome {
auto root = result.parseJson();
const auto it = this->badgeMap_.find(userID.string);
if (it != this->badgeMap_.end() && it->second->id.string == badgeID)
{
this->badgeMap_.erase(userID.string);
}
}
std::unique_lock lock(this->mutex_);
void SeventvBadges::registerBadge(const QJsonObject &badgeJson)
{
const auto badgeID = badgeJson["id"].toString();
int index = 0;
for (const auto &jsonBadge : root.value("badges").toArray())
{
auto badge = jsonBadge.toObject();
auto urls = badge.value("urls").toArray();
auto emote =
Emote{EmoteName{},
ImageSet{Url{urls.at(0).toArray().at(1).toString()},
Url{urls.at(1).toArray().at(1).toString()},
Url{urls.at(2).toArray().at(1).toString()}},
Tooltip{badge.value("tooltip").toString()}, Url{}};
const std::unique_lock lock(this->mutex_);
this->emotes_.push_back(
std::make_shared<const Emote>(std::move(emote)));
if (this->knownBadges_.find(badgeID) != this->knownBadges_.end())
{
return;
}
for (const auto &user : badge.value("users").toArray())
{
this->badgeMap_[user.toString()] = index;
}
++index;
}
auto emote = Emote{
.name = EmoteName{},
.images = SeventvEmotes::createImageSet(badgeJson),
.tooltip = Tooltip{badgeJson["tooltip"].toString()},
.homePage = Url{},
.id = EmoteId{badgeID},
};
return Success;
})
.execute();
if (emote.images.getImage1()->isEmpty())
{
return; // Bad images
}
this->knownBadges_[badgeID] =
std::make_shared<const Emote>(std::move(emote));
}
} // namespace chatterino

View file

@ -5,11 +5,11 @@
#include "util/QStringHash.hpp"
#include <boost/optional.hpp>
#include <QJsonObject>
#include <memory>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace chatterino {
@ -19,18 +19,27 @@ using EmotePtr = std::shared_ptr<const Emote>;
class SeventvBadges : public Singleton
{
public:
void initialize(Settings &settings, Paths &paths) override;
// Return the badge, if any, that is assigned to the user
boost::optional<EmotePtr> getBadge(const UserId &id) const;
boost::optional<EmotePtr> getBadge(const UserId &id);
// Assign the given badge to the user
void assignBadgeToUser(const QString &badgeID, const UserId &userID);
// Remove the given badge from the user
void clearBadgeFromUser(const QString &badgeID, const UserId &userID);
// Register a new known badge
// The json object will contain all information about the badge, like its ID & its images
void registerBadge(const QJsonObject &badgeJson);
private:
void loadSeventvBadges();
// Mutex for both `badgeMap_` and `knownBadges_`
mutable std::shared_mutex mutex_;
// Mutex for both `badgeMap_` and `emotes_`
std::shared_mutex mutex_;
std::unordered_map<QString, int> badgeMap_;
std::vector<EmotePtr> emotes_;
// user-id => badge
std::unordered_map<QString, EmotePtr> badgeMap_;
// badge-id => badge
std::unordered_map<QString, EmotePtr> knownBadges_;
};
} // namespace chatterino

View file

@ -0,0 +1,35 @@
#pragma once
#include <magic_enum.hpp>
namespace chatterino::seventv {
enum class CosmeticKind {
Badge,
Paint,
EmoteSet,
INVALID,
};
} // namespace chatterino::seventv
template <>
constexpr magic_enum::customize::customize_t
magic_enum::customize::enum_name<chatterino::seventv::CosmeticKind>(
chatterino::seventv::CosmeticKind value) noexcept
{
using chatterino::seventv::CosmeticKind;
switch (value)
{
case CosmeticKind::Badge:
return "BADGE";
case CosmeticKind::Paint:
return "PAINT";
case CosmeticKind::EmoteSet:
return "EMOTE_SET";
default:
return default_tag;
}
}

View file

@ -1,6 +1,6 @@
#include "providers/seventv/SeventvEmotes.hpp"
#include "common/NetworkRequest.hpp"
#include "common/Literals.hpp"
#include "common/NetworkResult.hpp"
#include "common/QLogging.hpp"
#include "messages/Emote.hpp"
@ -8,6 +8,7 @@
#include "messages/ImageSet.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/seventv/eventapi/Dispatch.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Settings.hpp"
@ -36,10 +37,6 @@ using namespace seventv::eventapi;
const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes.");
const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1");
const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1");
const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global");
const QString API_URL_EMOTE_SET("https://7tv.io/v3/emote-sets/%1");
struct CreateEmoteResult {
Emote emote;
EmoteId id;
@ -77,71 +74,6 @@ bool isZeroWidthRecommended(const QJsonObject &emoteData)
return flags.has(SeventvEmoteFlag::ZeroWidth);
}
ImageSet makeImageSet(const QJsonObject &emoteData)
{
auto host = emoteData["host"].toObject();
// "//cdn.7tv[...]"
auto baseUrl = host["url"].toString();
auto files = host["files"].toArray();
// TODO: emit four images
std::array<ImagePtr, 3> sizes;
double baseWidth = 0.0;
int nextSize = 0;
for (auto fileItem : files)
{
if (nextSize >= sizes.size())
{
break;
}
auto file = fileItem.toObject();
if (file["format"].toString() != "WEBP")
{
continue; // We only use webp
}
double width = file["width"].toDouble();
double scale = 1.0; // in relation to first image
if (baseWidth > 0.0)
{
scale = baseWidth / width;
}
else
{
// => this is the first image
baseWidth = width;
}
auto image = Image::fromUrl(
{QString("https:%1/%2").arg(baseUrl, file["name"].toString())},
scale);
sizes.at(nextSize) = image;
nextSize++;
}
if (nextSize < sizes.size())
{
// this should be really rare
// this means we didn't get all sizes of an emote
if (nextSize == 0)
{
qCDebug(chatterinoSeventv)
<< "Got file list without any eligible files";
// When this emote is typed, chatterino will crash.
return ImageSet{};
}
for (; nextSize < sizes.size(); nextSize++)
{
sizes.at(nextSize) = Image::getEmpty();
}
}
return ImageSet{sizes[0], sizes[1], sizes[2]};
}
Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal)
{
return Tooltip{QString("%1<br>%2 7TV Emote<br>By: %3")
@ -172,7 +104,7 @@ CreateEmoteResult createEmote(const QJsonObject &activeEmote,
? createAliasedTooltip(emoteName.string, baseEmoteName.string,
author.string, isGlobal)
: createTooltip(emoteName.string, author.string, isGlobal);
auto imageSet = makeImageSet(emoteData);
auto imageSet = SeventvEmotes::createImageSet(emoteData);
auto emote =
Emote({emoteName, imageSet, tooltip,
@ -247,6 +179,7 @@ EmotePtr createUpdatedEmote(const EmotePtr &oldEmote,
namespace chatterino {
using namespace seventv::eventapi;
using namespace literals;
SeventvEmotes::SeventvEmotes()
: global_(std::make_shared<EmoteMap>())
@ -281,24 +214,21 @@ void SeventvEmotes::loadGlobalEmotes()
qCDebug(chatterinoSeventv) << "Loading 7TV Global Emotes";
NetworkRequest(API_URL_GLOBAL_EMOTE_SET, NetworkRequestType::Get)
.timeout(30000)
.onSuccess([this](const NetworkResult &result) -> Outcome {
QJsonArray parsedEmotes = result.parseJson()["emotes"].toArray();
getSeventvAPI().getEmoteSet(
u"global"_s,
[this](const auto &json) {
QJsonArray parsedEmotes = json["emotes"].toArray();
auto emoteMap = parseEmotes(parsedEmotes, true);
qCDebug(chatterinoSeventv)
<< "Loaded" << emoteMap.size() << "7TV Global Emotes";
this->setGlobalEmotes(
std::make_shared<EmoteMap>(std::move(emoteMap)));
return Success;
})
.onError([](const NetworkResult &result) {
},
[](const auto &result) {
qCWarning(chatterinoSeventv)
<< "Couldn't load 7TV global emotes" << result.getData();
})
.execute();
});
}
void SeventvEmotes::setGlobalEmotes(std::shared_ptr<const EmoteMap> emotes)
@ -313,13 +243,12 @@ void SeventvEmotes::loadChannelEmotes(
qCDebug(chatterinoSeventv)
<< "Reloading 7TV Channel Emotes" << channelId << manualRefresh;
NetworkRequest(API_URL_USER.arg(channelId), NetworkRequestType::Get)
.timeout(20000)
.onSuccess([callback = std::move(callback), channel, channelId,
manualRefresh](const NetworkResult &result) -> Outcome {
auto json = result.parseJson();
auto emoteSet = json["emote_set"].toObject();
auto parsedEmotes = emoteSet["emotes"].toArray();
getSeventvAPI().getUserByTwitchID(
channelId,
[callback = std::move(callback), channel, channelId,
manualRefresh](const auto &json) {
const auto emoteSet = json["emote_set"].toObject();
const auto parsedEmotes = emoteSet["emotes"].toArray();
auto emoteMap = parseEmotes(parsedEmotes, false);
bool hasEmotes = !emoteMap.empty();
@ -350,7 +279,7 @@ void SeventvEmotes::loadChannelEmotes(
auto shared = channel.lock();
if (!shared)
{
return Success;
return;
}
if (manualRefresh)
@ -366,40 +295,37 @@ void SeventvEmotes::loadChannelEmotes(
makeSystemMessage(CHANNEL_HAS_NO_EMOTES));
}
}
return Success;
})
.onError(
[channelId, channel, manualRefresh](const NetworkResult &result) {
auto shared = channel.lock();
if (!shared)
},
[channelId, channel, manualRefresh](const auto &result) {
auto shared = channel.lock();
if (!shared)
{
return;
}
if (result.status() == 404)
{
qCWarning(chatterinoSeventv)
<< "Error occurred fetching 7TV emotes: "
<< result.parseJson();
if (manualRefresh)
{
return;
shared->addMessage(
makeSystemMessage(CHANNEL_HAS_NO_EMOTES));
}
if (result.status() == 404)
{
qCWarning(chatterinoSeventv)
<< "Error occurred fetching 7TV emotes: "
<< result.parseJson();
if (manualRefresh)
{
shared->addMessage(
makeSystemMessage(CHANNEL_HAS_NO_EMOTES));
}
}
else
{
// TODO: Auto retry in case of a timeout, with a delay
auto errorString = result.formatError();
qCWarning(chatterinoSeventv)
<< "Error fetching 7TV emotes for channel" << channelId
<< ", error" << errorString;
shared->addMessage(makeSystemMessage(
QStringLiteral("Failed to fetch 7TV channel "
"emotes. (Error: %1)")
.arg(errorString)));
}
})
.execute();
}
else
{
// TODO: Auto retry in case of a timeout, with a delay
auto errorString = result.formatError();
qCWarning(chatterinoSeventv)
<< "Error fetching 7TV emotes for channel" << channelId
<< ", error" << errorString;
shared->addMessage(makeSystemMessage(
QStringLiteral("Failed to fetch 7TV channel "
"emotes. (Error: %1)")
.arg(errorString)));
}
});
}
boost::optional<EmotePtr> SeventvEmotes::addEmote(
@ -479,11 +405,9 @@ void SeventvEmotes::getEmoteSet(
{
qCDebug(chatterinoSeventv) << "Loading 7TV Emote Set" << emoteSetId;
NetworkRequest(API_URL_EMOTE_SET.arg(emoteSetId), NetworkRequestType::Get)
.timeout(20000)
.onSuccess([callback = std::move(successCallback),
emoteSetId](const NetworkResult &result) -> Outcome {
auto json = result.parseJson();
getSeventvAPI().getEmoteSet(
emoteSetId,
[callback = std::move(successCallback), emoteSetId](const auto &json) {
auto parsedEmotes = json["emotes"].toArray();
auto emoteMap = parseEmotes(parsedEmotes, false);
@ -492,13 +416,74 @@ void SeventvEmotes::getEmoteSet(
<< "7TV Emotes from" << emoteSetId;
callback(std::move(emoteMap), json["name"].toString());
return Success;
})
.onError([emoteSetId, callback = std::move(errorCallback)](
const NetworkResult &result) {
},
[emoteSetId, callback = std::move(errorCallback)](const auto &result) {
callback(result.formatError());
})
.execute();
});
}
ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData)
{
auto host = emoteData["host"].toObject();
// "//cdn.7tv[...]"
auto baseUrl = host["url"].toString();
auto files = host["files"].toArray();
std::array<ImagePtr, 3> sizes;
double baseWidth = 0.0;
size_t nextSize = 0;
for (auto fileItem : files)
{
if (nextSize >= sizes.size())
{
break;
}
auto file = fileItem.toObject();
if (file["format"].toString() != "WEBP")
{
continue; // We only use webp
}
double width = file["width"].toDouble();
double scale = 1.0; // in relation to first image
if (baseWidth > 0.0)
{
scale = baseWidth / width;
}
else
{
// => this is the first image
baseWidth = width;
}
auto image = Image::fromUrl(
{QString("https:%1/%2").arg(baseUrl, file["name"].toString())},
scale);
sizes.at(nextSize) = image;
nextSize++;
}
if (nextSize < sizes.size())
{
// this should be really rare
// this means we didn't get all sizes of an emote
if (nextSize == 0)
{
qCDebug(chatterinoSeventv)
<< "Got file list without any eligible files";
// When this emote is typed, chatterino will crash.
return ImageSet{};
}
for (; nextSize < sizes.size(); nextSize++)
{
sizes.at(nextSize) = Image::getEmpty();
}
}
return ImageSet{sizes[0], sizes[1], sizes[2]};
}
} // namespace chatterino

View file

@ -5,12 +5,14 @@
#include "common/Atomic.hpp"
#include "common/FlagsEnum.hpp"
#include <QJsonObject>
#include <memory>
namespace chatterino {
class ImageSet;
class Channel;
namespace seventv::eventapi {
struct EmoteAddDispatch;
struct EmoteUpdateDispatch;
@ -61,6 +63,20 @@ struct Emote;
using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap;
enum class SeventvEmoteSetKind : uint8_t {
Global,
Personal,
Channel,
};
enum class SeventvEmoteSetFlag : uint32_t {
Immutable = (1 << 0),
Privileged = (1 << 1),
Personal = (1 << 2),
Commercial = (1 << 3),
};
using SeventvEmoteSetFlags = FlagsEnum<SeventvEmoteSetFlag>;
class SeventvEmotes final
{
public:
@ -120,6 +136,13 @@ public:
std::function<void(EmoteMap &&, QString)> successCallback,
std::function<void(QString)> errorCallback);
/**
* Creates an image set from a 7TV emote or badge.
*
* @param emoteData { host: { files: [], url } }
*/
static ImageSet createImageSet(const QJsonObject &emoteData);
private:
Atomic<std::shared_ptr<const EmoteMap>> global_;
};

View file

@ -1,8 +1,11 @@
#include "providers/seventv/SeventvEventAPI.hpp"
#include "Application.hpp"
#include "providers/seventv/eventapi/Client.hpp"
#include "providers/seventv/eventapi/Dispatch.hpp"
#include "providers/seventv/eventapi/Message.hpp"
#include "providers/seventv/SeventvBadges.hpp"
#include "providers/seventv/SeventvCosmetics.hpp"
#include <QJsonArray>
@ -10,6 +13,7 @@
namespace chatterino {
using namespace seventv;
using namespace seventv::eventapi;
SeventvEventAPI::SeventvEventAPI(
@ -35,6 +39,25 @@ void SeventvEventAPI::subscribeUser(const QString &userID,
}
}
void SeventvEventAPI::subscribeTwitchChannel(const QString &id)
{
if (this->subscribedTwitchChannels_.insert(id).second)
{
this->subscribe({
ChannelCondition{id},
SubscriptionType::CreateCosmetic,
});
this->subscribe({
ChannelCondition{id},
SubscriptionType::CreateEntitlement,
});
this->subscribe({
ChannelCondition{id},
SubscriptionType::DeleteEntitlement,
});
}
}
void SeventvEventAPI::unsubscribeEmoteSet(const QString &id)
{
if (this->subscribedEmoteSets_.erase(id) > 0)
@ -53,6 +76,25 @@ void SeventvEventAPI::unsubscribeUser(const QString &id)
}
}
void SeventvEventAPI::unsubscribeTwitchChannel(const QString &id)
{
if (this->subscribedTwitchChannels_.erase(id) > 0)
{
this->unsubscribe({
ChannelCondition{id},
SubscriptionType::CreateCosmetic,
});
this->unsubscribe({
ChannelCondition{id},
SubscriptionType::CreateEntitlement,
});
this->unsubscribe({
ChannelCondition{id},
SubscriptionType::DeleteEntitlement,
});
}
}
std::shared_ptr<BasicPubSubClient<Subscription>> SeventvEventAPI::createClient(
liveupdates::WebsocketClient &client, websocketpp::connection_hdl hdl)
{
@ -144,9 +186,49 @@ void SeventvEventAPI::handleDispatch(const Dispatch &dispatch)
this->onUserUpdate(dispatch);
}
break;
case SubscriptionType::CreateCosmetic: {
const CosmeticCreateDispatch cosmetic(dispatch);
if (cosmetic.validate())
{
this->onCosmeticCreate(cosmetic);
}
else
{
qCDebug(chatterinoSeventvEventAPI)
<< "Invalid cosmetic dispatch" << dispatch.body;
}
}
break;
case SubscriptionType::CreateEntitlement: {
const EntitlementCreateDeleteDispatch entitlement(dispatch);
if (entitlement.validate())
{
this->onEntitlementCreate(entitlement);
}
else
{
qCDebug(chatterinoSeventvEventAPI)
<< "Invalid entitlement create dispatch" << dispatch.body;
}
}
break;
case SubscriptionType::DeleteEntitlement: {
const EntitlementCreateDeleteDispatch entitlement(dispatch);
if (entitlement.validate())
{
this->onEntitlementDelete(entitlement);
}
else
{
qCDebug(chatterinoSeventvEventAPI)
<< "Invalid entitlement delete dispatch" << dispatch.body;
}
}
break;
default: {
qCDebug(chatterinoSeventvEventAPI)
<< "Unknown subscription type:" << (int)dispatch.type
<< "Unknown subscription type:"
<< magic_enum::enum_name(dispatch.type).data()
<< "body:" << dispatch.body;
}
break;
@ -261,4 +343,59 @@ void SeventvEventAPI::onUserUpdate(const Dispatch &dispatch)
}
}
// NOLINTBEGIN(readability-convert-member-functions-to-static)
void SeventvEventAPI::onCosmeticCreate(const CosmeticCreateDispatch &cosmetic)
{
// We're using `Application::instance` instead of getApp(), because we're not in the GUI thread.
// `seventvBadges` does its own locking.
auto *badges = Application::instance->seventvBadges;
switch (cosmetic.kind)
{
case CosmeticKind::Badge: {
badges->registerBadge(cosmetic.data);
}
break;
default:
break;
}
}
void SeventvEventAPI::onEntitlementCreate(
const EntitlementCreateDeleteDispatch &entitlement)
{
// We're using `Application::instance` instead of getApp(), because we're not in the GUI thread.
// `seventvBadges` does its own locking.
auto *badges = Application::instance->seventvBadges;
switch (entitlement.kind)
{
case CosmeticKind::Badge: {
badges->assignBadgeToUser(entitlement.refID,
UserId{entitlement.userID});
}
break;
default:
break;
}
}
void SeventvEventAPI::onEntitlementDelete(
const EntitlementCreateDeleteDispatch &entitlement)
{
// We're using `Application::instance` instead of getApp(), because we're not in the GUI thread.
// `seventvBadges` does its own locking.
auto *badges = Application::instance->seventvBadges;
switch (entitlement.kind)
{
case CosmeticKind::Badge: {
badges->clearBadgeFromUser(entitlement.refID,
UserId{entitlement.userID});
}
break;
default:
break;
}
}
// NOLINTEND(readability-convert-member-functions-to-static)
} // namespace chatterino

View file

@ -15,8 +15,12 @@ namespace seventv::eventapi {
struct EmoteUpdateDispatch;
struct EmoteRemoveDispatch;
struct UserConnectionUpdateDispatch;
struct CosmeticCreateDispatch;
struct EntitlementCreateDeleteDispatch;
} // namespace seventv::eventapi
class SeventvBadges;
class SeventvEventAPI
: public BasicPubSubManager<seventv::eventapi::Subscription>
{
@ -44,11 +48,20 @@ public:
* @param emoteSetID 7TV emote-set-id, may be empty.
*/
void subscribeUser(const QString &userID, const QString &emoteSetID);
/**
* Subscribes to cosmetics and entitlements in a Twitch channel
* if not already subscribed.
*
* @param id Twitch channel id
*/
void subscribeTwitchChannel(const QString &id);
/** Unsubscribes from a user by its 7TV user id */
void unsubscribeUser(const QString &id);
/** Unsubscribes from an emote-set by its id */
void unsubscribeEmoteSet(const QString &id);
/** Unsubscribes from cosmetics and entitlements in a Twitch channel */
void unsubscribeTwitchChannel(const QString &id);
protected:
std::shared_ptr<BasicPubSubClient<seventv::eventapi::Subscription>>
@ -64,11 +77,19 @@ private:
void onEmoteSetUpdate(const seventv::eventapi::Dispatch &dispatch);
void onUserUpdate(const seventv::eventapi::Dispatch &dispatch);
void onCosmeticCreate(
const seventv::eventapi::CosmeticCreateDispatch &cosmetic);
void onEntitlementCreate(
const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement);
void onEntitlementDelete(
const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement);
/** emote-set ids */
std::unordered_set<QString> subscribedEmoteSets_;
/** user ids */
std::unordered_set<QString> subscribedUsers_;
/** Twitch channel ids */
std::unordered_set<QString> subscribedTwitchChannels_;
std::chrono::milliseconds heartbeatInterval_;
};

View file

@ -1,5 +1,7 @@
#include "providers/seventv/eventapi/Dispatch.hpp"
#include <QJsonArray>
#include <utility>
namespace chatterino::seventv::eventapi {
@ -91,4 +93,45 @@ bool UserConnectionUpdateDispatch::validate() const
!this->emoteSetID.isEmpty();
}
CosmeticCreateDispatch::CosmeticCreateDispatch(const Dispatch &dispatch)
: data(dispatch.body["object"]["data"].toObject())
, kind(magic_enum::enum_cast<CosmeticKind>(
dispatch.body["object"]["kind"].toString().toStdString())
.value_or(CosmeticKind::INVALID))
{
}
bool CosmeticCreateDispatch::validate() const
{
return !this->data.empty() && this->kind != CosmeticKind::INVALID;
}
EntitlementCreateDeleteDispatch::EntitlementCreateDeleteDispatch(
const Dispatch &dispatch)
{
const auto obj = dispatch.body["object"].toObject();
this->refID = obj["ref_id"].toString();
this->kind = magic_enum::enum_cast<CosmeticKind>(
obj["kind"].toString().toStdString())
.value_or(CosmeticKind::INVALID);
const auto userConnections = obj["user"]["connections"].toArray();
for (const auto &connectionJson : userConnections)
{
const auto connection = connectionJson.toObject();
if (connection["platform"].toString() == "TWITCH")
{
this->userID = connection["id"].toString();
this->userName = connection["username"].toString();
break;
}
}
}
bool EntitlementCreateDeleteDispatch::validate() const
{
return !this->userID.isEmpty() && !this->userName.isEmpty() &&
!this->refID.isEmpty() && this->kind != CosmeticKind::INVALID;
}
} // namespace chatterino::seventv::eventapi

View file

@ -1,6 +1,7 @@
#pragma once
#include "providers/seventv/eventapi/Subscription.hpp"
#include "providers/seventv/SeventvCosmetics.hpp"
#include <QJsonObject>
#include <QString>
@ -67,4 +68,26 @@ struct UserConnectionUpdateDispatch {
bool validate() const;
};
struct CosmeticCreateDispatch {
QJsonObject data;
CosmeticKind kind;
CosmeticCreateDispatch(const Dispatch &dispatch);
bool validate() const;
};
struct EntitlementCreateDeleteDispatch {
/** id of the user */
QString userID;
QString userName;
/** id of the entitlement */
QString refID;
CosmeticKind kind;
EntitlementCreateDeleteDispatch(const Dispatch &dispatch);
bool validate() const;
};
} // namespace chatterino::seventv::eventapi

View file

@ -102,4 +102,34 @@ QDebug &operator<<(QDebug &dbg, const ObjectIDCondition &condition)
return dbg;
}
ChannelCondition::ChannelCondition(QString twitchID)
: twitchID(std::move(twitchID))
{
}
QJsonObject ChannelCondition::encode() const
{
QJsonObject obj;
obj["ctx"] = "channel";
obj["platform"] = "TWITCH";
obj["id"] = this->twitchID;
return obj;
}
QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition)
{
dbg << "{ twitchID:" << condition.twitchID << '}';
return dbg;
}
bool ChannelCondition::operator==(const ChannelCondition &rhs) const
{
return this->twitchID == rhs.twitchID;
}
bool ChannelCondition::operator!=(const ChannelCondition &rhs) const
{
return !(*this == rhs);
}
} // namespace chatterino::seventv::eventapi

View file

@ -12,9 +12,22 @@ namespace chatterino::seventv::eventapi {
// https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#subscription-types
enum class SubscriptionType {
AnyEmoteSet,
CreateEmoteSet,
UpdateEmoteSet,
UpdateUser,
AnyCosmetic,
CreateCosmetic,
UpdateCosmetic,
DeleteCosmetic,
AnyEntitlement,
CreateEntitlement,
UpdateEntitlement,
DeleteEntitlement,
INVALID,
};
@ -46,7 +59,19 @@ struct ObjectIDCondition {
bool operator!=(const ObjectIDCondition &rhs) const;
};
using Condition = std::variant<ObjectIDCondition>;
struct ChannelCondition {
ChannelCondition(QString twitchID);
QString twitchID;
QJsonObject encode() const;
friend QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition);
bool operator==(const ChannelCondition &rhs) const;
bool operator!=(const ChannelCondition &rhs) const;
};
using Condition = std::variant<ObjectIDCondition, ChannelCondition>;
struct Subscription {
bool operator==(const Subscription &rhs) const;
@ -70,10 +95,30 @@ constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name<
using chatterino::seventv::eventapi::SubscriptionType;
switch (value)
{
case SubscriptionType::AnyEmoteSet:
return "emote_set.*";
case SubscriptionType::CreateEmoteSet:
return "emote_set.create";
case SubscriptionType::UpdateEmoteSet:
return "emote_set.update";
case SubscriptionType::UpdateUser:
return "user.update";
case SubscriptionType::AnyCosmetic:
return "cosmetic.*";
case SubscriptionType::CreateCosmetic:
return "cosmetic.create";
case SubscriptionType::UpdateCosmetic:
return "cosmetic.update";
case SubscriptionType::DeleteCosmetic:
return "cosmetic.delete";
case SubscriptionType::AnyEntitlement:
return "entitlement.*";
case SubscriptionType::CreateEntitlement:
return "entitlement.create";
case SubscriptionType::UpdateEntitlement:
return "entitlement.update";
case SubscriptionType::DeleteEntitlement:
return "entitlement.delete";
default:
return default_tag;
@ -91,6 +136,15 @@ struct hash<chatterino::seventv::eventapi::ObjectIDCondition> {
}
};
template <>
struct hash<chatterino::seventv::eventapi::ChannelCondition> {
size_t operator()(
const chatterino::seventv::eventapi::ChannelCondition &c) const
{
return qHash(c.twitchID);
}
};
template <>
struct hash<chatterino::seventv::eventapi::Subscription> {
size_t operator()(

View file

@ -3,7 +3,7 @@
#include "Application.hpp"
#include "common/Channel.hpp"
#include "common/Env.hpp"
#include "common/NetworkRequest.hpp"
#include "common/NetworkResult.hpp"
#include "common/Outcome.hpp"
#include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp"
@ -12,6 +12,7 @@
#include "messages/MessageBuilder.hpp"
#include "providers/irc/IrcMessageBuilder.hpp"
#include "providers/IvrApi.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchCommon.hpp"
#include "singletons/Emotes.hpp"
@ -445,4 +446,36 @@ void TwitchAccount::autoModDeny(const QString msgID, ChannelPtr channel)
});
}
const QString &TwitchAccount::getSeventvUserID() const
{
return this->seventvUserID_;
}
void TwitchAccount::loadSeventvUserID()
{
if (this->isAnon())
{
return;
}
if (!this->seventvUserID_.isEmpty())
{
return;
}
getSeventvAPI().getUserByTwitchID(
this->getUserId(),
[this](const auto &json) {
const auto id = json["user"]["id"].toString();
if (!id.isEmpty())
{
this->seventvUserID_ = id;
}
return Success;
},
[](const auto &result) {
qCDebug(chatterinoSeventv)
<< "Failed to load 7TV user-id:" << result.formatError();
});
}
} // namespace chatterino

View file

@ -59,6 +59,12 @@ public:
const QString &getOAuthClient() const;
const QString &getUserId() const;
/**
* The Seventv user-id of the current user.
* Empty if there's no associated Seventv user with this twitch user.
*/
const QString &getSeventvUserID() const;
QColor color();
void setColor(QColor color);
@ -98,6 +104,8 @@ public:
void autoModAllow(const QString msgID, ChannelPtr channel);
void autoModDeny(const QString msgID, ChannelPtr channel);
void loadSeventvUserID();
private:
QString oauthClient_;
QString oauthToken_;
@ -115,6 +123,8 @@ private:
// std::map<UserId, TwitchAccountEmoteData> emotes;
UniqueAccess<TwitchAccountEmoteData> emotes_;
UniqueAccess<std::unordered_map<QString, EmoteMap>> localEmotes_;
QString seventvUserID_;
};
} // namespace chatterino

View file

@ -17,6 +17,7 @@ TwitchAccountManager::TwitchAccountManager()
this->currentUserChanged.connect([this] {
auto currentUser = this->getCurrent();
currentUser->loadBlocks();
currentUser->loadSeventvUserID();
});
this->accounts.itemRemoved.connect([this](const auto &acc) {

View file

@ -20,6 +20,7 @@
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/RecentMessagesApi.hpp"
#include "providers/seventv/eventapi/Dispatch.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/seventv/SeventvEventAPI.hpp"
#include "providers/twitch/api/Helix.hpp"
@ -40,6 +41,7 @@
#include <IrcConnection>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QThread>
@ -105,6 +107,7 @@ TwitchChannel::TwitchChannel(const QString &name)
this->refreshBTTVChannelEmotes(false);
this->refreshSevenTVChannelEmotes(false);
this->joinBttvChannel();
this->listenSevenTVCosmetics();
getIApp()->getTwitchLiveController()->add(
std::dynamic_pointer_cast<TwitchChannel>(shared_from_this()));
});
@ -248,6 +251,12 @@ TwitchChannel::~TwitchChannel()
{
getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId());
}
if (getApp()->twitch->seventvEventAPI)
{
getApp()->twitch->seventvEventAPI->unsubscribeTwitchChannel(
this->roomId());
}
}
void TwitchChannel::initialize()
@ -600,6 +609,7 @@ void TwitchChannel::sendMessage(const QString &message)
bool messageSent = false;
this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent);
this->updateSevenTVActivity();
if (messageSent)
{
@ -1564,4 +1574,60 @@ boost::optional<CheerEmote> TwitchChannel::cheerEmote(const QString &string)
return boost::none;
}
void TwitchChannel::updateSevenTVActivity()
{
static const QString seventvActivityUrl =
QStringLiteral("https://7tv.io/v3/users/%1/presences");
const auto currentSeventvUserID =
getApp()->accounts->twitch.getCurrent()->getSeventvUserID();
if (currentSeventvUserID.isEmpty())
{
return;
}
if (!getSettings()->enableSevenTVEventAPI ||
!getSettings()->sendSevenTVActivity)
{
return;
}
if (this->nextSeventvActivity_.isValid() &&
QDateTime::currentDateTimeUtc() < this->nextSeventvActivity_)
{
return;
}
// Make sure to not send activity again before receiving the response
this->nextSeventvActivity_ = this->nextSeventvActivity_.addSecs(300);
qCDebug(chatterinoSeventv) << "Sending activity in" << this->getName();
getSeventvAPI().updatePresence(
this->roomId(), currentSeventvUserID,
[chan = weakOf<Channel>(this)]() {
const auto self =
std::dynamic_pointer_cast<TwitchChannel>(chan.lock());
if (!self)
{
return Success;
}
self->nextSeventvActivity_ =
QDateTime::currentDateTimeUtc().addSecs(60);
return Success;
},
[](const auto &result) {
qCDebug(chatterinoSeventv)
<< "Failed to update 7TV activity:" << result.formatError();
});
}
void TwitchChannel::listenSevenTVCosmetics()
{
if (getApp()->twitch->seventvEventAPI)
{
getApp()->twitch->seventvEventAPI->subscribeTwitchChannel(
this->roomId());
}
}
} // namespace chatterino

View file

@ -254,6 +254,12 @@ private:
void showLoginMessage();
/** Joins (subscribes to) a Twitch channel for updates on BTTV. */
void joinBttvChannel() const;
/**
* Indicates an activity to 7TV in this channel for this user.
* This is done at most once every 60s.
*/
void updateSevenTVActivity();
void listenSevenTVCosmetics();
/**
* @brief Sets the live status of this Twitch channel
@ -372,6 +378,12 @@ private:
*/
size_t seventvUserTwitchConnectionIndex_;
/**
* The next moment in time to signal activity in this channel to 7TV.
* Or: Up until this moment we don't need to send activity.
*/
QDateTime nextSeventvActivity_;
/** The platform of the last live emote update ("7TV", "BTTV", "FFZ"). */
QString lastLiveUpdateEmotePlatform_;
/** The actor name of the last live emote update. */

View file

@ -268,6 +268,7 @@ public:
BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true};
BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true};
BoolSetting enableSevenTVEventAPI = {"/emotes/seventv/eventapi", true};
BoolSetting sendSevenTVActivity = {"/emotes/seventv/sendActivity", true};
/// Links
BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false};

View file

@ -560,6 +560,11 @@ void GeneralPage::initLayout(GeneralPageView &layout)
layout.addCheckbox("Show 7TV channel emotes", s.enableSevenTVChannelEmotes);
layout.addCheckbox("Enable 7TV live emote updates (requires restart)",
s.enableSevenTVEventAPI);
layout.addCheckbox("Send activity to 7TV", s.sendSevenTVActivity, false,
"When enabled, Chatterino will signal an activity to "
"7TV when you send a chat mesage. This is used for "
"badges, paints, and personal emotes. When disabled, no "
"activity is sent and others won't see your cosmetics.");
layout.addTitle("Streamer Mode");
layout.addDescription(

View file

@ -38,6 +38,15 @@ int main(int argc, char **argv)
chatterino::NetworkManager::deinit();
settingsDir.remove();
// Pick up the last events from the eventloop
// Using a loop to catch events queueing other events (e.g. deletions)
for (size_t i = 0; i < 32; i++)
{
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
QApplication::exit(res);
});