feat: Add Live Emote Updates for BTTV (#4147)

This feature is enabled by default and can be disabled in settings with the "Enable BTTV live emotes updates" setting.

Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix 2023-01-21 15:06:55 +01:00 committed by GitHub
parent 56f7c91a64
commit 904749cf62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 856 additions and 33 deletions

View file

@ -6,7 +6,7 @@ on:
workflow_dispatch:
env:
TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.5
TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:master
concurrency:
group: test-${{ github.ref }}

View file

@ -2,6 +2,7 @@
## Unversioned
- Major: Added live emote updates for BTTV (#4147)
- Minor: Change the highlight order to prioritize Message highlights over User highlights. (#4303)
- Minor: Added ability to negate search options by prefixing it with an exclamation mark (e.g. `!badge:mod` to search for messages where the author does not have the moderator badge). (#4207)
- Minor: Search window input will automatically use currently selected text if present. (#4178)

View file

@ -14,6 +14,7 @@
#include "debug/AssertInGuiThread.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/bttv/BttvLiveUpdates.hpp"
#include "providers/chatterino/ChatterinoBadges.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/irc/Irc2.hpp"
@ -156,6 +157,7 @@ void Application::initialize(Settings &settings, Paths &paths)
}
this->initPubSub();
this->initBttvLiveUpdates();
this->initSeventvEventAPI();
}
@ -576,6 +578,51 @@ void Application::initPubSub()
RequestModerationActions();
}
void Application::initBttvLiveUpdates()
{
if (!this->twitch->bttvLiveUpdates)
{
qCDebug(chatterinoBttv)
<< "Skipping initialization of Live Updates as it's disabled";
return;
}
this->twitch->bttvLiveUpdates->signals_.emoteAdded.connect(
[&](const auto &data) {
auto chan = this->twitch->getChannelOrEmptyByID(data.channelID);
postToThread([chan, data] {
if (auto *channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->addBttvEmote(data);
}
});
});
this->twitch->bttvLiveUpdates->signals_.emoteUpdated.connect(
[&](const auto &data) {
auto chan = this->twitch->getChannelOrEmptyByID(data.channelID);
postToThread([chan, data] {
if (auto *channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->updateBttvEmote(data);
}
});
});
this->twitch->bttvLiveUpdates->signals_.emoteRemoved.connect(
[&](const auto &data) {
auto chan = this->twitch->getChannelOrEmptyByID(data.channelID);
postToThread([chan, data] {
if (auto *channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->removeBttvEmote(data);
}
});
});
this->twitch->bttvLiveUpdates->start();
}
void Application::initSeventvEventAPI()
{
if (!this->twitch->seventvEventAPI)

View file

@ -149,6 +149,7 @@ public:
private:
void addSingleton(Singleton *singleton);
void initPubSub();
void initBttvLiveUpdates();
void initSeventvEventAPI();
void initNm(Paths &paths);

View file

@ -197,6 +197,13 @@ set(SOURCE_FILES
providers/bttv/BttvEmotes.cpp
providers/bttv/BttvEmotes.hpp
providers/bttv/BttvLiveUpdates.cpp
providers/bttv/BttvLiveUpdates.hpp
providers/bttv/liveupdates/BttvLiveUpdateMessages.cpp
providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp
providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp
providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp
providers/chatterino/ChatterinoBadges.cpp
providers/chatterino/ChatterinoBadges.hpp

View file

@ -593,17 +593,37 @@ MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteMessageTag /*unused*/,
const QString &oldEmoteName)
: MessageBuilder()
{
auto text = QString("renamed %1 emote %2 to %3.")
.arg(platform, oldEmoteName, emoteName);
QString text;
if (actor.isEmpty())
{
text = "Renamed";
}
else
{
text = "renamed";
}
text +=
QString(" %1 emote %2 to %3.").arg(platform, oldEmoteName, emoteName);
this->emplace<TimestampElement>();
this->emplace<TextElement>(actor, MessageElementFlag::Username,
MessageColor::System)
->setLink({Link::UserInfo, actor});
if (!actor.isEmpty())
{
this->emplace<TextElement>(actor, MessageElementFlag::Username,
MessageColor::System)
->setLink({Link::UserInfo, actor});
}
this->emplace<TextElement>(text, MessageElementFlag::Text,
MessageColor::System);
auto finalText = QString("%1 %2").arg(actor, text);
QString finalText;
if (actor.isEmpty())
{
finalText = text;
}
else
{
finalText = QString("%1 %2").arg(actor, text);
}
this->message().loginName = actor;
this->message().messageText = finalText;

View file

@ -7,6 +7,7 @@
#include "messages/Image.hpp"
#include "messages/ImageSet.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Settings.hpp"
@ -21,6 +22,12 @@ namespace {
QString emoteLinkFormat("https://betterttv.com/emotes/%1");
struct CreateEmoteResult {
EmoteId id;
EmoteName name;
Emote emote;
};
Url getEmoteLink(QString urlTemplate, const EmoteId &id,
const QString &emoteScale)
{
@ -70,6 +77,70 @@ namespace {
return {Success, std::move(emotes)};
}
CreateEmoteResult createChannelEmote(const QString &channelDisplayName,
const QJsonObject &jsonEmote)
{
auto id = EmoteId{jsonEmote.value("id").toString()};
auto name = EmoteName{jsonEmote.value("code").toString()};
auto author = EmoteAuthor{
jsonEmote.value("user").toObject().value("displayName").toString()};
auto emote = Emote({
name,
ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25),
},
Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(author.string.isEmpty() ? "Channel" : "Shared")
.arg(author.string.isEmpty() ? channelDisplayName
: author.string)},
Url{emoteLinkFormat.arg(id.string)},
false,
id,
});
return {id, name, emote};
}
bool updateChannelEmote(Emote &emote, const QString &channelDisplayName,
const QJsonObject &jsonEmote)
{
bool anyModifications = false;
if (jsonEmote.contains("code"))
{
emote.name = EmoteName{jsonEmote.value("code").toString()};
anyModifications = true;
}
if (jsonEmote.contains("user"))
{
emote.author = EmoteAuthor{jsonEmote.value("user")
.toObject()
.value("displayName")
.toString()};
anyModifications = true;
}
if (anyModifications)
{
emote.tooltip = Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(emote.name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(emote.author.string.isEmpty() ? "Channel" : "Shared")
.arg(emote.author.string.isEmpty() ? channelDisplayName
: emote.author.string)};
}
return anyModifications;
}
std::pair<Outcome, EmoteMap> parseChannelEmotes(
const QJsonObject &jsonRoot, const QString &channelDisplayName)
{
@ -80,33 +151,11 @@ namespace {
auto jsonEmotes = jsonRoot.value(key).toArray();
for (auto jsonEmote_ : jsonEmotes)
{
auto jsonEmote = jsonEmote_.toObject();
auto emote = createChannelEmote(channelDisplayName,
jsonEmote_.toObject());
auto id = EmoteId{jsonEmote.value("id").toString()};
auto name = EmoteName{jsonEmote.value("code").toString()};
auto author = EmoteAuthor{jsonEmote.value("user")
.toObject()
.value("displayName")
.toString()};
auto emote = Emote({
name,
ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25),
},
Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(author.string.isEmpty() ? "Channel" : "Shared")
.arg(author.string.isEmpty() ? channelDisplayName
: author.string)},
Url{emoteLinkFormat.arg(id.string)},
});
emotes[name] = cachedOrMake(std::move(emote), id);
emotes[emote.name] =
cachedOrMake(std::move(emote.emote), emote.id);
}
};
@ -227,6 +276,78 @@ void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel,
.execute();
}
EmotePtr BttvEmotes::addEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
auto result = createChannelEmote(channelDisplayName, message.jsonEmote);
auto emote = std::make_shared<const Emote>(std::move(result.emote));
updatedMap[result.name] = emote;
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return emote;
}
boost::optional<std::pair<EmotePtr, EmotePtr>> BttvEmotes::updateEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
// Step 1: remove the existing emote
auto it = updatedMap.findEmote(QString(), message.emoteID);
if (it == updatedMap.end())
{
// We already copied the map at this point and are now discarding the copy.
// This is fine, because this case should be really rare.
return boost::none;
}
auto oldEmotePtr = it->second;
// copy the existing emote, to not change the original one
auto emote = *oldEmotePtr;
updatedMap.erase(it);
// Step 2: update the emote
if (!updateChannelEmote(emote, channelDisplayName, message.jsonEmote))
{
// The emote wasn't actually updated
return boost::none;
}
auto name = emote.name;
auto emotePtr = std::make_shared<const Emote>(std::move(emote));
updatedMap[name] = emotePtr;
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return std::make_pair(oldEmotePtr, emotePtr);
}
boost::optional<EmotePtr> BttvEmotes::removeEmote(
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteRemoveMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
auto it = updatedMap.findEmote(QString(), message.emoteID);
if (it == updatedMap.end())
{
// We already copied the map at this point and are now discarding the copy.
// This is fine, because this case should be really rare.
return boost::none;
}
auto emote = it->second;
updatedMap.erase(it);
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return emote;
}
/*
static Url getEmoteLink(QString urlTemplate, const EmoteId &id,
const QString &emoteScale)

View file

@ -13,6 +13,8 @@ struct Emote;
using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap;
class Channel;
struct BttvLiveUpdateEmoteUpdateAddMessage;
struct BttvLiveUpdateEmoteRemoveMessage;
class BttvEmotes final
{
@ -33,6 +35,41 @@ public:
std::function<void(EmoteMap &&)> callback,
bool manualRefresh);
/**
* Adds an emote to the `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return The added emote.
*/
static EmotePtr addEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message);
/**
* Updates an emote in this `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return pair<old emote, new emote> if any emote was updated.
*/
static boost::optional<std::pair<EmotePtr, EmotePtr>> updateEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message);
/**
* Removes an emote from this `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return The removed emote if any emote was removed.
*/
static boost::optional<EmotePtr> removeEmote(
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteRemoveMessage &message);
private:
Atomic<std::shared_ptr<const EmoteMap>> global_;
};

View file

@ -0,0 +1,92 @@
#include "providers/bttv/BttvLiveUpdates.hpp"
#include <QJsonDocument>
#include <utility>
namespace chatterino {
BttvLiveUpdates::BttvLiveUpdates(QString host)
: BasicPubSubManager(std::move(host))
{
}
void BttvLiveUpdates::joinChannel(const QString &channelID,
const QString &userName)
{
if (this->joinedChannels_.insert(channelID).second)
{
this->subscribe({BttvLiveUpdateSubscriptionChannel{channelID}});
this->subscribe({BttvLiveUpdateBroadcastMe{.twitchID = channelID,
.userName = userName}});
}
}
void BttvLiveUpdates::partChannel(const QString &id)
{
if (this->joinedChannels_.erase(id) > 0)
{
this->unsubscribe({BttvLiveUpdateSubscriptionChannel{id}});
}
}
void BttvLiveUpdates::onMessage(
websocketpp::connection_hdl /*hdl*/,
BasicPubSubManager<BttvLiveUpdateSubscription>::WebsocketMessagePtr msg)
{
const auto &payload = QString::fromStdString(msg->get_payload());
QJsonDocument jsonDoc(QJsonDocument::fromJson(payload.toUtf8()));
if (jsonDoc.isNull())
{
qCDebug(chatterinoBttv) << "Failed to parse live update JSON";
return;
}
auto json = jsonDoc.object();
auto eventType = json["name"].toString();
auto eventData = json["data"].toObject();
if (eventType == "emote_create")
{
auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid add message" << json;
return;
}
this->signals_.emoteAdded.invoke(message);
}
else if (eventType == "emote_update")
{
auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid update message" << json;
return;
}
this->signals_.emoteUpdated.invoke(message);
}
else if (eventType == "emote_delete")
{
auto message = BttvLiveUpdateEmoteRemoveMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid deletion message" << json;
return;
}
this->signals_.emoteRemoved.invoke(message);
}
else
{
qCDebug(chatterinoBttv) << "Unhandled event:" << json;
}
}
} // namespace chatterino

View file

@ -0,0 +1,57 @@
#pragma once
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp"
#include "providers/liveupdates/BasicPubSubManager.hpp"
#include "util/QStringHash.hpp"
#include <pajlada/signals/signal.hpp>
#include <unordered_set>
namespace chatterino {
class BttvLiveUpdates : public BasicPubSubManager<BttvLiveUpdateSubscription>
{
template <typename T>
using Signal =
pajlada::Signals::Signal<T>; // type-id is vector<T, Alloc<T>>
public:
BttvLiveUpdates(QString host);
struct {
Signal<BttvLiveUpdateEmoteUpdateAddMessage> emoteAdded;
Signal<BttvLiveUpdateEmoteUpdateAddMessage> emoteUpdated;
Signal<BttvLiveUpdateEmoteRemoveMessage> emoteRemoved;
} signals_; // NOLINT(readability-identifier-naming)
/**
* Joins a Twitch channel by its id (without any prefix like 'twitch:')
* if it's not already joined.
*
* @param channelID the Twitch channel-id of the broadcaster.
* @param userName the Twitch username of the current user.
*/
void joinChannel(const QString &channelID, const QString &userName);
/**
* Parts a twitch channel by its id (without any prefix like 'twitch:')
* if it's joined.
*
* @param id the Twitch channel-id of the broadcaster.
*/
void partChannel(const QString &id);
protected:
void onMessage(
websocketpp::connection_hdl hdl,
BasicPubSubManager<BttvLiveUpdateSubscription>::WebsocketMessagePtr msg)
override;
private:
// Contains all joined Twitch channel-ids
std::unordered_set<QString> joinedChannels_;
};
} // namespace chatterino

View file

@ -0,0 +1,52 @@
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
namespace {
bool tryParseChannelId(QString &channelId)
{
if (!channelId.startsWith("twitch:"))
{
return false;
}
channelId.remove(0, 7); // "twitch:"
return true;
}
} // namespace
namespace chatterino {
BttvLiveUpdateEmoteUpdateAddMessage::BttvLiveUpdateEmoteUpdateAddMessage(
const QJsonObject &json)
: channelID(json["channel"].toString())
, jsonEmote(json["emote"].toObject())
, emoteName(this->jsonEmote["code"].toString())
, emoteID(this->jsonEmote["id"].toString())
, badChannelID_(!tryParseChannelId(this->channelID))
{
}
bool BttvLiveUpdateEmoteUpdateAddMessage::validate() const
{
// We don't need to check for jsonEmote["code"]/["id"],
// because these are this->emoteID and this->emoteName.
return !this->badChannelID_ && !this->channelID.isEmpty() &&
!this->emoteID.isEmpty() && !this->emoteName.isEmpty();
}
BttvLiveUpdateEmoteRemoveMessage::BttvLiveUpdateEmoteRemoveMessage(
const QJsonObject &json)
: channelID(json["channel"].toString())
, emoteID(json["emoteId"].toString())
, badChannelID_(!tryParseChannelId(this->channelID))
{
}
bool BttvLiveUpdateEmoteRemoveMessage::validate() const
{
return !this->badChannelID_ && !this->emoteID.isEmpty() &&
!this->channelID.isEmpty();
}
} // namespace chatterino

View file

@ -0,0 +1,38 @@
#pragma once
#include <QJsonObject>
namespace chatterino {
struct BttvLiveUpdateEmoteUpdateAddMessage {
BttvLiveUpdateEmoteUpdateAddMessage(const QJsonObject &json);
QString channelID;
QJsonObject jsonEmote;
QString emoteName;
QString emoteID;
bool validate() const;
private:
// true if the channel id is malformed
// (e.g. doesn't start with "twitch:")
bool badChannelID_;
};
struct BttvLiveUpdateEmoteRemoveMessage {
BttvLiveUpdateEmoteRemoveMessage(const QJsonObject &json);
QString channelID;
QString emoteID;
bool validate() const;
private:
// true if the channel id is malformed
// (e.g. doesn't start with "twitch:")
bool badChannelID_;
};
} // namespace chatterino

View file

@ -0,0 +1,107 @@
#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp"
#include <QJsonDocument>
namespace chatterino {
QByteArray BttvLiveUpdateSubscription::encodeSubscribe() const
{
return QJsonDocument(std::visit(
[](const auto &d) {
return d.encode(true);
},
this->data))
.toJson();
}
QByteArray BttvLiveUpdateSubscription::encodeUnsubscribe() const
{
return QJsonDocument(std::visit(
[](const auto &d) {
return d.encode(false);
},
this->data))
.toJson();
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscription &subscription)
{
std::visit(
[&](const auto &data) {
dbg << data;
},
subscription.data);
return dbg;
}
QJsonObject BttvLiveUpdateSubscriptionChannel::encode(bool isSubscribe) const
{
QJsonObject root;
if (isSubscribe)
{
root["name"] = "join_channel";
}
else
{
root["name"] = "part_channel";
}
QJsonObject data;
data["name"] = QString("twitch:%1").arg(this->twitchID);
root["data"] = data;
return root;
}
bool BttvLiveUpdateSubscriptionChannel::operator==(
const BttvLiveUpdateSubscriptionChannel &rhs) const
{
return this->twitchID == rhs.twitchID;
}
bool BttvLiveUpdateSubscriptionChannel::operator!=(
const BttvLiveUpdateSubscriptionChannel &rhs) const
{
return !(*this == rhs);
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscriptionChannel &data)
{
dbg << "BttvLiveUpdateSubscriptionChannel{ twitchID:" << data.twitchID
<< '}';
return dbg;
}
QJsonObject BttvLiveUpdateBroadcastMe::encode(bool /*isSubscribe*/) const
{
QJsonObject root;
root["name"] = "broadcast_me";
QJsonObject data;
data["name"] = this->userName;
data["channel"] = QString("twitch:%1").arg(this->twitchID);
root["data"] = data;
return root;
}
bool BttvLiveUpdateBroadcastMe::operator==(
const BttvLiveUpdateBroadcastMe &rhs) const
{
return this->twitchID == rhs.twitchID && this->userName == rhs.userName;
}
bool BttvLiveUpdateBroadcastMe::operator!=(
const BttvLiveUpdateBroadcastMe &rhs) const
{
return !(*this == rhs);
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateBroadcastMe &data)
{
dbg << "BttvLiveUpdateBroadcastMe{ twitchID:" << data.twitchID
<< "userName:" << data.userName << '}';
return dbg;
}
} // namespace chatterino

View file

@ -0,0 +1,89 @@
#pragma once
#include <boost/functional/hash.hpp>
#include <QByteArray>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <variant>
namespace chatterino {
struct BttvLiveUpdateSubscriptionChannel {
QString twitchID;
QJsonObject encode(bool isSubscribe) const;
bool operator==(const BttvLiveUpdateSubscriptionChannel &rhs) const;
bool operator!=(const BttvLiveUpdateSubscriptionChannel &rhs) const;
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateSubscriptionChannel &data);
};
struct BttvLiveUpdateBroadcastMe {
QString twitchID;
QString userName;
QJsonObject encode(bool isSubscribe) const;
bool operator==(const BttvLiveUpdateBroadcastMe &rhs) const;
bool operator!=(const BttvLiveUpdateBroadcastMe &rhs) const;
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateBroadcastMe &data);
};
using BttvLiveUpdateSubscriptionData =
std::variant<BttvLiveUpdateSubscriptionChannel, BttvLiveUpdateBroadcastMe>;
struct BttvLiveUpdateSubscription {
BttvLiveUpdateSubscriptionData data;
QByteArray encodeSubscribe() const;
QByteArray encodeUnsubscribe() const;
bool operator==(const BttvLiveUpdateSubscription &rhs) const
{
return this->data == rhs.data;
}
bool operator!=(const BttvLiveUpdateSubscription &rhs) const
{
return !(*this == rhs);
}
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateSubscription &subscription);
};
} // namespace chatterino
namespace std {
template <>
struct hash<chatterino::BttvLiveUpdateSubscriptionChannel> {
size_t operator()(
const chatterino::BttvLiveUpdateSubscriptionChannel &data) const
{
return qHash(data.twitchID);
}
};
template <>
struct hash<chatterino::BttvLiveUpdateBroadcastMe> {
size_t operator()(const chatterino::BttvLiveUpdateBroadcastMe &data) const
{
size_t seed = 0;
boost::hash_combine(seed, qHash(data.twitchID));
boost::hash_combine(seed, qHash(data.userName));
return seed;
}
};
template <>
struct hash<chatterino::BttvLiveUpdateSubscription> {
size_t operator()(const chatterino::BttvLiveUpdateSubscription &sub) const
{
return std::hash<chatterino::BttvLiveUpdateSubscriptionData>{}(
sub.data);
}
};
} // namespace std

View file

@ -15,6 +15,8 @@
#include "messages/MessageElement.hpp"
#include "messages/MessageThread.hpp"
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/bttv/BttvLiveUpdates.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/RecentMessagesApi.hpp"
#include "providers/seventv/eventapi/SeventvEventAPIDispatch.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
@ -103,6 +105,7 @@ TwitchChannel::TwitchChannel(const QString &name)
this->refreshFFZChannelEmotes(false);
this->refreshBTTVChannelEmotes(false);
this->refreshSevenTVChannelEmotes(false);
this->joinBttvChannel();
});
this->connected.connect([this]() {
@ -121,6 +124,11 @@ TwitchChannel::TwitchChannel(const QString &name)
this->destroyed.connect([this]() {
getApp()->twitch->dropSeventvChannel(this->seventvUserID_,
this->seventvEmoteSetID_);
if (getApp()->twitch->bttvLiveUpdates)
{
getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId());
}
});
this->messageRemovedFromStart.connect([this](MessagePtr &msg) {
@ -622,6 +630,66 @@ const QString &TwitchChannel::seventvEmoteSetID() const
return this->seventvEmoteSetID_;
}
void TwitchChannel::joinBttvChannel() const
{
if (getApp()->twitch->bttvLiveUpdates)
{
const auto currentAccount = getApp()->accounts->twitch.getCurrent();
QString userName;
if (currentAccount && !currentAccount->isAnon())
{
userName = currentAccount->getUserName();
}
getApp()->twitch->bttvLiveUpdates->joinChannel(this->roomId(),
userName);
}
}
void TwitchChannel::addBttvEmote(
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
auto emote = BttvEmotes::addEmote(this->getDisplayName(), this->bttvEmotes_,
message);
this->addOrReplaceLiveUpdatesAddRemove(true, "BTTV", QString() /*actor*/,
emote->name.string);
}
void TwitchChannel::updateBttvEmote(
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
auto updated = BttvEmotes::updateEmote(this->getDisplayName(),
this->bttvEmotes_, message);
if (!updated)
{
return;
}
const auto [oldEmote, newEmote] = *updated;
if (oldEmote->name == newEmote->name)
{
return; // only the creator changed
}
auto builder = MessageBuilder(liveUpdatesUpdateEmoteMessage, "BTTV",
QString() /* actor */, newEmote->name.string,
oldEmote->name.string);
this->addMessage(builder.release());
}
void TwitchChannel::removeBttvEmote(
const BttvLiveUpdateEmoteRemoveMessage &message)
{
auto removed = BttvEmotes::removeEmote(this->bttvEmotes_, message);
if (!removed)
{
return;
}
this->addOrReplaceLiveUpdatesAddRemove(false, "BTTV", QString() /*actor*/,
removed.get()->name.string);
}
void TwitchChannel::addSeventvEmote(
const SeventvEventAPIEmoteAddDispatch &dispatch)
{

View file

@ -47,6 +47,8 @@ class EmoteMap;
class TwitchBadges;
class FfzEmotes;
class BttvEmotes;
struct BttvLiveUpdateEmoteUpdateAddMessage;
struct BttvLiveUpdateEmoteRemoveMessage;
class SeventvEmotes;
struct SeventvEventAPIEmoteAddDispatch;
struct SeventvEventAPIEmoteUpdateDispatch;
@ -139,6 +141,13 @@ public:
const QString &seventvUserID() const;
const QString &seventvEmoteSetID() const;
/** Adds a BTTV channel emote to this channel. */
void addBttvEmote(const BttvLiveUpdateEmoteUpdateAddMessage &message);
/** Updates a BTTV channel emote in this channel. */
void updateBttvEmote(const BttvLiveUpdateEmoteUpdateAddMessage &message);
/** Removes a BTTV channel emote from this channel. */
void removeBttvEmote(const BttvLiveUpdateEmoteRemoveMessage &message);
/** Adds a 7TV channel emote to this channel. */
void addSeventvEmote(const SeventvEventAPIEmoteAddDispatch &dispatch);
/** Updates a 7TV channel emote's name in this channel */
@ -206,6 +215,8 @@ private:
void fetchDisplayName();
void cleanUpReplyThreads();
void showLoginMessage();
/** Joins (subscribes to) a Twitch channel for updates on BTTV. */
void joinBttvChannel() const;
void setLive(bool newLiveStatus);
void setMod(bool value);

View file

@ -6,6 +6,7 @@
#include "controllers/accounts/AccountController.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/bttv/BttvLiveUpdates.hpp"
#include "providers/seventv/eventapi/SeventvEventAPISubscription.hpp"
#include "providers/seventv/SeventvEventAPI.hpp"
#include "providers/twitch/api/Helix.hpp"
@ -30,6 +31,7 @@ using namespace std::chrono_literals;
namespace {
const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws";
const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3";
} // namespace
@ -45,6 +47,14 @@ TwitchIrcServer::TwitchIrcServer()
this->initializeIrc();
this->pubsub = new PubSub(TWITCH_PUBSUB_URL);
if (getSettings()->enableBTTVLiveUpdates &&
getSettings()->enableBTTVChannelEmotes)
{
this->bttvLiveUpdates =
std::make_unique<BttvLiveUpdates>(BTTV_LIVE_UPDATES_URL);
}
if (getSettings()->enableSevenTVEventAPI &&
getSettings()->enableSevenTVChannelEmotes)
{

View file

@ -20,6 +20,7 @@ class Settings;
class Paths;
class PubSub;
class TwitchChannel;
class BttvLiveUpdates;
class SeventvEventAPI;
class TwitchIrcServer final : public AbstractIrcServer, public Singleton
@ -66,6 +67,7 @@ public:
IndirectChannel watchingChannel;
PubSub *pubsub;
std::unique_ptr<BttvLiveUpdates> bttvLiveUpdates;
std::unique_ptr<SeventvEventAPI> seventvEventAPI;
const BttvEmotes &getBttvEmotes() const;

View file

@ -225,6 +225,7 @@ public:
BoolSetting enableBTTVGlobalEmotes = {"/emotes/bttv/global", true};
BoolSetting enableBTTVChannelEmotes = {"/emotes/bttv/channel", true};
BoolSetting enableBTTVLiveUpdates = {"/emotes/bttv/liveupdates", true};
BoolSetting enableFFZGlobalEmotes = {"/emotes/ffz/global", true};
BoolSetting enableFFZChannelEmotes = {"/emotes/ffz/channel", true};
BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true};

View file

@ -432,6 +432,8 @@ void GeneralPage::initLayout(GeneralPageView &layout)
s.emojiSet);
layout.addCheckbox("Show BTTV global emotes", s.enableBTTVGlobalEmotes);
layout.addCheckbox("Show BTTV channel emotes", s.enableBTTVChannelEmotes);
layout.addCheckbox("Enable BTTV live emote updates (requires restart)",
s.enableBTTVLiveUpdates);
layout.addCheckbox("Show FFZ global emotes", s.enableFFZGlobalEmotes);
layout.addCheckbox("Show FFZ channel emotes", s.enableFFZChannelEmotes);
layout.addCheckbox("Show 7TV global emotes", s.enableSevenTVGlobalEmotes);

View file

@ -22,6 +22,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp
${CMAKE_CURRENT_LIST_DIR}/src/BasicPubSub.cpp
${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp
${CMAKE_CURRENT_LIST_DIR}/src/BttvLiveUpdates.cpp
# Add your new file above this line!
)

View file

@ -0,0 +1,59 @@
#include "providers/bttv/BttvLiveUpdates.hpp"
#include <boost/optional.hpp>
#include <gtest/gtest.h>
#include <QString>
using namespace chatterino;
using namespace std::chrono_literals;
const QString TARGET_USER_ID = "1234567";
const QString TARGET_USER_NAME = "Alien";
TEST(BttvLiveUpdates, AllEvents)
{
const QString host("wss://127.0.0.1:9050/liveupdates/bttv/all-events");
auto *liveUpdates = new BttvLiveUpdates(host);
liveUpdates->start();
boost::optional<BttvLiveUpdateEmoteUpdateAddMessage> addMessage;
boost::optional<BttvLiveUpdateEmoteUpdateAddMessage> updateMessage;
boost::optional<BttvLiveUpdateEmoteRemoveMessage> removeMessage;
liveUpdates->signals_.emoteAdded.connect([&](const auto &m) {
addMessage = m;
});
liveUpdates->signals_.emoteUpdated.connect([&](const auto &m) {
updateMessage = m;
});
liveUpdates->signals_.emoteRemoved.connect([&](const auto &m) {
removeMessage = m;
});
std::this_thread::sleep_for(50ms);
liveUpdates->joinChannel(TARGET_USER_ID, TARGET_USER_NAME);
std::this_thread::sleep_for(500ms);
ASSERT_EQ(liveUpdates->diag.connectionsOpened, 1);
ASSERT_EQ(liveUpdates->diag.connectionsClosed, 0);
ASSERT_EQ(liveUpdates->diag.connectionsFailed, 0);
auto add = *addMessage;
ASSERT_EQ(add.channelID, TARGET_USER_ID);
ASSERT_EQ(add.emoteName, QString("PepePls"));
ASSERT_EQ(add.emoteID, QString("55898e122612142e6aaa935b"));
auto update = *updateMessage;
ASSERT_EQ(update.channelID, TARGET_USER_ID);
ASSERT_EQ(update.emoteName, QString("PepePls"));
ASSERT_EQ(update.emoteID, QString("55898e122612142e6aaa935b"));
auto rem = *removeMessage;
ASSERT_EQ(rem.channelID, TARGET_USER_ID);
ASSERT_EQ(rem.emoteID, QString("55898e122612142e6aaa935b"));
liveUpdates->stop();
ASSERT_EQ(liveUpdates->diag.connectionsOpened, 1);
ASSERT_EQ(liveUpdates->diag.connectionsClosed, 1);
ASSERT_EQ(liveUpdates->diag.connectionsFailed, 0);
}