Improve Twitch PubSub connection reliability (#3643)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix
2022-05-07 17:22:39 +02:00
committed by GitHub
parent 4aa5b04e37
commit f97780d84e
64 changed files with 3094 additions and 2126 deletions
+124 -34
View File
@@ -17,7 +17,7 @@
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/irc/Irc2.hpp"
#include "providers/twitch/PubsubClient.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include "singletons/Emotes.hpp"
@@ -31,6 +31,7 @@
#include "singletons/Toasts.hpp"
#include "singletons/Updates.hpp"
#include "singletons/WindowManager.hpp"
#include "util/Helpers.hpp"
#include "util/IsBigEndian.hpp"
#include "util/PostToThread.hpp"
#include "util/RapidjsonHelpers.hpp"
@@ -137,7 +138,7 @@ void Application::initialize(Settings &settings, Paths &paths)
{
this->initNm(paths);
}
this->initPubsub();
this->initPubSub();
}
int Application::run(QApplication &qtApp)
@@ -194,7 +195,7 @@ void Application::initNm(Paths &paths)
#endif
}
void Application::initPubsub()
void Application::initPubSub()
{
this->twitch->pubsub->signals_.moderation.chatCleared.connect(
[this](const auto &action) {
@@ -331,21 +332,105 @@ void Application::initPubsub()
});
});
this->twitch->pubsub->signals_.moderation.automodMessage.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
const auto handleAutoModMessage = [&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
if (chan->isEmpty())
{
return;
}
postToThread([chan, action] {
const auto p = makeAutomodMessage(action);
chan->addMessage(p.first);
chan->addMessage(p.second);
});
postToThread([chan, action] {
const auto p = makeAutomodMessage(action);
chan->addMessage(p.first);
chan->addMessage(p.second);
});
};
this->twitch->pubsub->signals_.moderation.autoModMessageCaught.connect(
[&](const auto &msg, const QString &channelID) {
switch (msg.type)
{
case PubSubAutoModQueueMessage::Type::AutoModCaughtMessage: {
if (msg.status == "PENDING")
{
AutomodAction action(msg.data, channelID);
action.reason = QString("%1 level %2")
.arg(msg.contentCategory)
.arg(msg.contentLevel);
action.msgID = msg.messageID;
action.message = msg.messageText;
// this message also contains per-word automod data, which could be implemented
// extract sender data manually because Twitch loves not being consistent
QString senderDisplayName =
msg.senderUserDisplayName; // Might be transformed later
bool hasLocalizedName = false;
if (!msg.senderUserDisplayName.isEmpty())
{
// check for non-ascii display names
if (QString::compare(msg.senderUserDisplayName,
msg.senderUserLogin,
Qt::CaseInsensitive) != 0)
{
hasLocalizedName = true;
}
}
QColor senderColor = msg.senderUserChatColor;
QString senderColor_;
if (!senderColor.isValid() &&
getSettings()->colorizeNicknames)
{
// color may be not present if user is a grey-name
senderColor = getRandomColor(msg.senderUserID);
}
// handle username style based on prefered setting
switch (getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
if (hasLocalizedName)
{
senderDisplayName = msg.senderUserLogin;
}
break;
}
case UsernameDisplayMode::LocalizedName: {
break;
}
case UsernameDisplayMode::
UsernameAndLocalizedName: {
if (hasLocalizedName)
{
senderDisplayName = QString("%1(%2)").arg(
msg.senderUserLogin,
msg.senderUserDisplayName);
}
break;
}
}
action.target =
ActionUser{msg.senderUserID, msg.senderUserLogin,
senderDisplayName, senderColor};
handleAutoModMessage(action);
}
// "ALLOWED" and "DENIED" statuses remain unimplemented
// They are versions of automod_message_(denied|approved) but for mods.
}
break;
case PubSubAutoModQueueMessage::Type::INVALID:
default: {
}
break;
}
});
this->twitch->pubsub->signals_.moderation.autoModMessageBlocked.connect(
handleAutoModMessage);
this->twitch->pubsub->signals_.moderation.automodUserMessage.connect(
[&](const auto &action) {
@@ -381,39 +466,44 @@ void Application::initPubsub()
this->twitch->pubsub->signals_.pointReward.redeemed.connect(
[&](auto &data) {
QString channelId;
if (rj::getSafe(data, "channel_id", channelId))
{
auto chan = this->twitch->getChannelOrEmptyByID(channelId);
auto reward = ChannelPointReward(data);
postToThread([chan, reward] {
if (auto channel =
dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->addChannelPointReward(reward);
}
});
}
else
QString channelId = data.value("channel_id").toString();
if (channelId.isEmpty())
{
qCDebug(chatterinoApp)
<< "Couldn't find channel id of point reward";
return;
}
auto chan = this->twitch->getChannelOrEmptyByID(channelId);
auto reward = ChannelPointReward(data);
postToThread([chan, reward] {
if (auto channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->addChannelPointReward(reward);
}
});
});
this->twitch->pubsub->start();
auto RequestModerationActions = [=]() {
this->twitch->pubsub->unlistenAllModerationActions();
this->twitch->pubsub->setAccount(
getApp()->accounts->twitch.getCurrent());
// TODO(pajlada): Unlisten to all authed topics instead of only
// moderation topics this->twitch->pubsub->UnlistenAllAuthedTopics();
this->twitch->pubsub->listenToWhispers(
this->accounts->twitch.getCurrent());
this->twitch->pubsub->listenToWhispers();
};
this->accounts->twitch.currentUserChanged.connect(
[=] {
this->twitch->pubsub->unlistenAllModerationActions();
this->twitch->pubsub->unlistenWhispers();
},
boost::signals2::at_front);
this->accounts->twitch.currentUserChanged.connect(RequestModerationActions);
RequestModerationActions();
+1 -1
View File
@@ -66,7 +66,7 @@ public:
private:
void addSingleton(Singleton *singleton);
void initPubsub();
void initPubSub();
void initNm(Paths &paths);
template <typename T,
+27 -6
View File
@@ -210,12 +210,16 @@ set(SOURCE_FILES
providers/twitch/ChannelPointReward.hpp
providers/twitch/IrcMessageHandler.cpp
providers/twitch/IrcMessageHandler.hpp
providers/twitch/PubsubActions.cpp
providers/twitch/PubsubActions.hpp
providers/twitch/PubsubClient.cpp
providers/twitch/PubsubClient.hpp
providers/twitch/PubsubHelpers.cpp
providers/twitch/PubsubHelpers.hpp
providers/twitch/PubSubActions.cpp
providers/twitch/PubSubActions.hpp
providers/twitch/PubSubClient.cpp
providers/twitch/PubSubClient.hpp
providers/twitch/PubSubClientOptions.hpp
providers/twitch/PubSubHelpers.hpp
providers/twitch/PubSubManager.cpp
providers/twitch/PubSubManager.hpp
providers/twitch/PubSubMessages.hpp
providers/twitch/PubSubWebsocket.hpp
providers/twitch/TwitchAccount.cpp
providers/twitch/TwitchAccount.hpp
providers/twitch/TwitchAccountManager.cpp
@@ -237,6 +241,22 @@ set(SOURCE_FILES
providers/twitch/TwitchUser.cpp
providers/twitch/TwitchUser.hpp
providers/twitch/pubsubmessages/AutoMod.cpp
providers/twitch/pubsubmessages/AutoMod.hpp
providers/twitch/pubsubmessages/Base.cpp
providers/twitch/pubsubmessages/Base.hpp
providers/twitch/pubsubmessages/ChannelPoints.cpp
providers/twitch/pubsubmessages/ChannelPoints.hpp
providers/twitch/pubsubmessages/ChatModeratorAction.cpp
providers/twitch/pubsubmessages/ChatModeratorAction.hpp
providers/twitch/pubsubmessages/Listen.cpp
providers/twitch/pubsubmessages/Listen.hpp
providers/twitch/pubsubmessages/Message.hpp
providers/twitch/pubsubmessages/Unlisten.cpp
providers/twitch/pubsubmessages/Unlisten.hpp
providers/twitch/pubsubmessages/Whisper.cpp
providers/twitch/pubsubmessages/Whisper.hpp
providers/twitch/api/Helix.cpp
providers/twitch/api/Helix.hpp
@@ -510,6 +530,7 @@ target_link_libraries(${LIBRARY_PROJECT}
Threads::Threads
RapidJSON::RapidJSON
LRUCache
MagicEnum
)
if (BUILD_WITH_QTKEYCHAIN)
target_link_libraries(${LIBRARY_PROJECT}
+1 -1
View File
@@ -28,7 +28,7 @@ Q_LOGGING_CATEGORY(chatterinoNotification, "chatterino.notification",
logThreshold);
Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader",
logThreshold);
Q_LOGGING_CATEGORY(chatterinoPubsub, "chatterino.pubsub", logThreshold);
Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold);
Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold);
Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode",
logThreshold);
+1 -1
View File
@@ -21,7 +21,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader);
Q_DECLARE_LOGGING_CATEGORY(chatterinoPubsub);
Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub);
Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink);
Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "Application.hpp"
#include "MessageElement.hpp"
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "singletons/Theme.hpp"
#include "util/DebugCount.hpp"
#include "util/IrcHelpers.hpp"
+1 -1
View File
@@ -7,7 +7,7 @@
#include "messages/Message.hpp"
#include "messages/MessageElement.hpp"
#include "providers/LinkResolver.hpp"
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Resources.hpp"
#include "singletons/Theme.hpp"
+19 -101
View File
@@ -1,97 +1,38 @@
#include "ChannelPointReward.hpp"
#include "common/QLogging.hpp"
#include "util/RapidjsonHelpers.hpp"
namespace chatterino {
QString parseRewardImage(const rapidjson::Value &obj, const char *key,
bool &result)
ChannelPointReward::ChannelPointReward(const QJsonObject &redemption)
{
QString url;
if (!(result = rj::getSafe(obj, key, url)))
{
qCDebug(chatterinoTwitch)
<< "No url value found for key in reward image object:" << key;
return "";
}
auto reward = redemption.value("reward").toObject();
return url;
}
ChannelPointReward::ChannelPointReward(rapidjson::Value &redemption)
{
rapidjson::Value user;
if (!(this->hasParsedSuccessfully =
rj::getSafeObject(redemption, "user", user)))
{
qCDebug(chatterinoTwitch) << "No user info found for redemption";
return;
}
rapidjson::Value reward;
if (!(this->hasParsedSuccessfully =
rj::getSafeObject(redemption, "reward", reward)))
{
qCDebug(chatterinoTwitch) << "No reward info found for redemption";
return;
}
if (!(this->hasParsedSuccessfully = rj::getSafe(reward, "id", this->id)))
{
qCDebug(chatterinoTwitch) << "No id found for reward";
return;
}
if (!(this->hasParsedSuccessfully =
rj::getSafe(reward, "channel_id", this->channelId)))
{
qCDebug(chatterinoTwitch) << "No channel_id found for reward";
return;
}
if (!(this->hasParsedSuccessfully =
rj::getSafe(reward, "title", this->title)))
{
qCDebug(chatterinoTwitch) << "No title found for reward";
return;
}
if (!(this->hasParsedSuccessfully =
rj::getSafe(reward, "cost", this->cost)))
{
qCDebug(chatterinoTwitch) << "No cost found for reward";
return;
}
if (!(this->hasParsedSuccessfully = rj::getSafe(
reward, "is_user_input_required", this->isUserInputRequired)))
{
qCDebug(chatterinoTwitch)
<< "No information if user input is required found for reward";
return;
}
this->id = reward.value("id").toString();
this->channelId = reward.value("channel_id").toString();
this->title = reward.value("title").toString();
this->cost = reward.value("cost").toInt();
this->isUserInputRequired = reward.value("is_user_input_required").toBool();
// We don't need to store user information for rewards with user input
// because we will get the user info from a corresponding IRC message
if (!this->isUserInputRequired)
{
this->parseUser(user);
auto user = redemption.value("user").toObject();
this->user.id = user.value("id").toString();
this->user.login = user.value("login").toString();
this->user.displayName = user.value("display_name").toString();
}
rapidjson::Value obj;
if (rj::getSafeObject(reward, "image", obj) && !obj.IsNull() &&
obj.IsObject())
auto imageValue = reward.value("image");
if (imageValue.isObject())
{
auto imageObject = imageValue.toObject();
this->image = ImageSet{
Image::fromUrl(
{parseRewardImage(obj, "url_1x", this->hasParsedSuccessfully)},
1),
Image::fromUrl(
{parseRewardImage(obj, "url_2x", this->hasParsedSuccessfully)},
0.5),
Image::fromUrl(
{parseRewardImage(obj, "url_4x", this->hasParsedSuccessfully)},
0.25),
Image::fromUrl({imageObject.value("url_1x").toString()}, 1),
Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5),
Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25),
};
}
else
@@ -104,27 +45,4 @@ ChannelPointReward::ChannelPointReward(rapidjson::Value &redemption)
}
}
void ChannelPointReward::parseUser(rapidjson::Value &user)
{
if (!(this->hasParsedSuccessfully = rj::getSafe(user, "id", this->user.id)))
{
qCDebug(chatterinoTwitch) << "No id found for user in reward";
return;
}
if (!(this->hasParsedSuccessfully =
rj::getSafe(user, "login", this->user.login)))
{
qCDebug(chatterinoTwitch) << "No login name found for user in reward";
return;
}
if (!(this->hasParsedSuccessfully =
rj::getSafe(user, "display_name", this->user.displayName)))
{
qCDebug(chatterinoTwitch) << "No display name found for user in reward";
return;
}
}
} // namespace chatterino
+2 -6
View File
@@ -4,7 +4,7 @@
#include "messages/Image.hpp"
#include "messages/ImageSet.hpp"
#include <rapidjson/document.h>
#include <QJsonObject>
#define TWITCH_CHANNEL_POINT_REWARD_URL(x) \
QString("https://static-cdn.jtvnw.net/custom-reward-images/default-%1") \
@@ -12,14 +12,13 @@
namespace chatterino {
struct ChannelPointReward {
ChannelPointReward(rapidjson::Value &reward);
ChannelPointReward(const QJsonObject &redemption);
ChannelPointReward() = delete;
QString id;
QString channelId;
QString title;
int cost;
ImageSet image;
bool hasParsedSuccessfully = false;
bool isUserInputRequired = false;
struct {
@@ -27,9 +26,6 @@ struct ChannelPointReward {
QString login;
QString displayName;
} user;
private:
void parseUser(rapidjson::Value &user);
};
} // namespace chatterino
+13
View File
@@ -0,0 +1,13 @@
#include "providers/twitch/PubSubActions.hpp"
namespace chatterino {
PubSubAction::PubSubAction(const QJsonObject &data, const QString &_roomID)
: timestamp(std::chrono::steady_clock::now())
, roomID(_roomID)
{
this->source.id = data.value("created_by_user_id").toString();
this->source.login = data.value("created_by").toString();
}
} // namespace chatterino
@@ -1,7 +1,7 @@
#pragma once
#include <rapidjson/document.h>
#include <QColor>
#include <QJsonObject>
#include <QString>
#include <chrono>
@@ -15,10 +15,24 @@ struct ActionUser {
// displayName should be in format "login(localizedName)" for non-ascii usernames
QString displayName;
QColor color;
inline bool operator==(const ActionUser &rhs) const
{
return this->id == rhs.id && this->login == rhs.login &&
this->displayName == rhs.displayName && this->color == rhs.color;
}
};
inline QDebug operator<<(QDebug dbg, const ActionUser &user)
{
dbg.nospace() << "ActionUser(" << user.id << ", " << user.login << ", "
<< user.displayName << ", " << user.color << ")";
return dbg.maybeSpace();
}
struct PubSubAction {
PubSubAction(const rapidjson::Value &data, const QString &_roomID);
PubSubAction(const QJsonObject &data, const QString &_roomID);
ActionUser source;
std::chrono::steady_clock::time_point timestamp;
+212
View File
@@ -0,0 +1,212 @@
#include "providers/twitch/PubSubClient.hpp"
#include "common/QLogging.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "providers/twitch/PubSubHelpers.hpp"
#include "providers/twitch/PubSubMessages.hpp"
#include "providers/twitch/pubsubmessages/Unlisten.hpp"
#include "singletons/Settings.hpp"
#include "util/DebugCount.hpp"
#include "util/Helpers.hpp"
#include "util/RapidjsonHelpers.hpp"
#include <exception>
#include <thread>
namespace chatterino {
static const char *PING_PAYLOAD = R"({"type":"PING"})";
PubSubClient::PubSubClient(WebsocketClient &websocketClient,
WebsocketHandle handle,
const PubSubClientOptions &clientOptions)
: websocketClient_(websocketClient)
, handle_(handle)
, clientOptions_(clientOptions)
{
}
void PubSubClient::start()
{
assert(!this->started_);
this->started_ = true;
this->ping();
}
void PubSubClient::stop()
{
assert(this->started_);
this->started_ = false;
}
void PubSubClient::close(const std::string &reason,
websocketpp::close::status::value code)
{
WebsocketErrorCode ec;
auto conn = this->websocketClient_.get_con_from_hdl(this->handle_, ec);
if (ec)
{
qCDebug(chatterinoPubSub)
<< "Error getting con:" << ec.message().c_str();
return;
}
conn->close(code, reason, ec);
if (ec)
{
qCDebug(chatterinoPubSub) << "Error closing:" << ec.message().c_str();
return;
}
}
bool PubSubClient::listen(PubSubListenMessage msg)
{
int numRequestedListens = msg.topics.size();
if (this->numListens_ + numRequestedListens > PubSubClient::MAX_LISTENS)
{
// This PubSubClient is already at its peak listens
return false;
}
this->numListens_ += numRequestedListens;
DebugCount::increase("PubSub topic pending listens", numRequestedListens);
for (const auto &topic : msg.topics)
{
this->listeners_.emplace_back(Listener{topic, false, false, false});
}
qCDebug(chatterinoPubSub)
<< "Subscribing to" << numRequestedListens << "topics";
this->send(msg.toJson());
return true;
}
PubSubClient::UnlistenPrefixResponse PubSubClient::unlistenPrefix(
const QString &prefix)
{
std::vector<QString> topics;
for (auto it = this->listeners_.begin(); it != this->listeners_.end();)
{
const auto &listener = *it;
if (listener.topic.startsWith(prefix))
{
topics.push_back(listener.topic);
it = this->listeners_.erase(it);
}
else
{
++it;
}
}
if (topics.empty())
{
return {{}, ""};
}
auto numRequestedUnlistens = topics.size();
this->numListens_ -= numRequestedUnlistens;
DebugCount::increase("PubSub topic pending unlistens",
numRequestedUnlistens);
PubSubUnlistenMessage message(topics);
this->send(message.toJson());
return {message.topics, message.nonce};
}
void PubSubClient::handleListenResponse(const PubSubMessage &message)
{
}
void PubSubClient::handleUnlistenResponse(const PubSubMessage &message)
{
}
void PubSubClient::handlePong()
{
assert(this->awaitingPong_);
this->awaitingPong_ = false;
}
bool PubSubClient::isListeningToTopic(const QString &topic)
{
for (const auto &listener : this->listeners_)
{
if (listener.topic == topic)
{
return true;
}
}
return false;
}
std::vector<Listener> PubSubClient::getListeners() const
{
return this->listeners_;
}
void PubSubClient::ping()
{
assert(this->started_);
if (this->awaitingPong_)
{
qCDebug(chatterinoPubSub) << "No pong response, disconnect!";
this->close("Didn't respond to ping");
return;
}
if (!this->send(PING_PAYLOAD))
{
return;
}
this->awaitingPong_ = true;
auto self = this->shared_from_this();
runAfter(this->websocketClient_.get_io_service(),
this->clientOptions_.pingInterval_, [self](auto timer) {
if (!self->started_)
{
return;
}
self->ping();
});
}
bool PubSubClient::send(const char *payload)
{
WebsocketErrorCode ec;
this->websocketClient_.send(this->handle_, payload,
websocketpp::frame::opcode::text, ec);
if (ec)
{
qCDebug(chatterinoPubSub) << "Error sending message" << payload << ":"
<< ec.message().c_str();
// TODO(pajlada): Check which error code happened and maybe
// gracefully handle it
return false;
}
return true;
}
} // namespace chatterino
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "providers/twitch/PubSubClientOptions.hpp"
#include "providers/twitch/PubSubMessages.hpp"
#include "providers/twitch/PubSubWebsocket.hpp"
#include <QString>
#include <pajlada/signals/signal.hpp>
#include <atomic>
#include <vector>
namespace chatterino {
struct TopicData {
QString topic;
bool authed{false};
bool persistent{false};
};
struct Listener : TopicData {
bool confirmed{false};
};
class PubSubClient : public std::enable_shared_from_this<PubSubClient>
{
public:
struct UnlistenPrefixResponse {
std::vector<QString> topics;
QString nonce;
};
// The max amount of topics we may listen to with a single connection
static constexpr std::vector<QString>::size_type MAX_LISTENS = 50;
PubSubClient(WebsocketClient &_websocketClient, WebsocketHandle _handle,
const PubSubClientOptions &clientOptions);
void start();
void stop();
void close(const std::string &reason,
websocketpp::close::status::value code =
websocketpp::close::status::normal);
bool listen(PubSubListenMessage msg);
UnlistenPrefixResponse unlistenPrefix(const QString &prefix);
void handleListenResponse(const PubSubMessage &message);
void handleUnlistenResponse(const PubSubMessage &message);
void handlePong();
bool isListeningToTopic(const QString &topic);
std::vector<Listener> getListeners() const;
private:
void ping();
bool send(const char *payload);
WebsocketClient &websocketClient_;
WebsocketHandle handle_;
uint16_t numListens_ = 0;
std::vector<Listener> listeners_;
std::atomic<bool> awaitingPong_{false};
std::atomic<bool> started_{false};
const PubSubClientOptions &clientOptions_;
};
} // namespace chatterino
@@ -0,0 +1,14 @@
#pragma once
#include <chrono>
namespace chatterino {
/**
* @brief Options to change the behaviour of the underlying websocket clients
**/
struct PubSubClientOptions {
std::chrono::seconds pingInterval_;
};
} // namespace chatterino
@@ -1,5 +1,6 @@
#pragma once
#include <QJsonObject>
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>
#include <memory>
@@ -11,19 +12,6 @@ namespace chatterino {
class TwitchAccount;
struct ActionUser;
const rapidjson::Value &getArgs(const rapidjson::Value &data);
const rapidjson::Value &getMsgID(const rapidjson::Value &data);
bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user);
bool getTargetUser(const rapidjson::Value &data, ActionUser &user);
bool getTargetUserName(const rapidjson::Value &data, ActionUser &user);
rapidjson::Document createListenMessage(const std::vector<QString> &topicsVec,
std::shared_ptr<TwitchAccount> account);
rapidjson::Document createUnlistenMessage(
const std::vector<QString> &topicsVec);
// Create timer using given ioService
template <typename Duration, typename Callback>
void runAfter(boost::asio::io_service &ioService, Duration duration,
@@ -35,7 +23,7 @@ void runAfter(boost::asio::io_service &ioService, Duration duration,
timer->async_wait([timer, cb](const boost::system::error_code &ec) {
if (ec)
{
qCDebug(chatterinoPubsub)
qCDebug(chatterinoPubSub)
<< "Error in runAfter:" << ec.message().c_str();
return;
}
@@ -54,7 +42,7 @@ void runAfter(std::shared_ptr<boost::asio::steady_timer> timer,
timer->async_wait([timer, cb](const boost::system::error_code &ec) {
if (ec)
{
qCDebug(chatterinoPubsub)
qCDebug(chatterinoPubSub)
<< "Error in runAfter:" << ec.message().c_str();
return;
}
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
#pragma once
#include "providers/twitch/ChatterinoWebSocketppLogger.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "providers/twitch/PubSubClient.hpp"
#include "providers/twitch/PubSubClientOptions.hpp"
#include "providers/twitch/PubSubMessages.hpp"
#include "providers/twitch/PubSubWebsocket.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "util/ExponentialBackoff.hpp"
#include <QJsonObject>
#include <QString>
#include <pajlada/signals/signal.hpp>
#include <websocketpp/client.hpp>
#include <atomic>
#include <chrono>
#include <map>
#include <memory>
#include <thread>
#include <unordered_map>
#include <vector>
namespace chatterino {
class PubSub
{
using WebsocketMessagePtr =
websocketpp::config::asio_tls_client::message_type::ptr;
using WebsocketContextPtr =
websocketpp::lib::shared_ptr<boost::asio::ssl::context>;
template <typename T>
using Signal =
pajlada::Signals::Signal<T>; // type-id is vector<T, Alloc<T>>
struct NonceInfo {
std::weak_ptr<PubSubClient> client;
QString messageType; // e.g. LISTEN or UNLISTEN
std::vector<QString> topics;
std::vector<QString>::size_type topicCount;
};
WebsocketClient websocketClient;
std::unique_ptr<std::thread> mainThread;
// Account credentials
// Set from setAccount or setAccountData
QString token_;
QString userID_;
public:
// The max amount of connections we may open
static constexpr int maxConnections = 10;
PubSub(const QString &host,
std::chrono::seconds pingInterval = std::chrono::seconds(15));
void setAccount(std::shared_ptr<TwitchAccount> account)
{
this->token_ = account->getOAuthToken();
this->userID_ = account->getUserId();
}
void setAccountData(QString token, QString userID)
{
this->token_ = token;
this->userID_ = userID;
}
~PubSub() = delete;
enum class State {
Connected,
Disconnected,
};
void start();
void stop();
bool isConnected() const
{
return this->state == State::Connected;
}
struct {
struct {
Signal<ClearChatAction> chatCleared;
Signal<DeleteAction> messageDeleted;
Signal<ModeChangedAction> modeChanged;
Signal<ModerationStateAction> moderationStateChanged;
Signal<BanAction> userBanned;
Signal<UnbanAction> userUnbanned;
// Message caught by automod
// channelID
pajlada::Signals::Signal<PubSubAutoModQueueMessage, QString>
autoModMessageCaught;
// Message blocked by moderator
Signal<AutomodAction> autoModMessageBlocked;
Signal<AutomodUserAction> automodUserMessage;
Signal<AutomodInfoAction> automodInfoMessage;
} moderation;
struct {
// Parsing should be done in PubSubManager as well,
// but for now we just send the raw data
Signal<const PubSubWhisperMessage &> received;
Signal<const PubSubWhisperMessage &> sent;
} whisper;
struct {
Signal<const QJsonObject &> redeemed;
} pointReward;
} signals_;
void unlistenAllModerationActions();
void unlistenWhispers();
bool listenToWhispers();
void listenToChannelModerationActions(const QString &channelID);
void listenToAutomod(const QString &channelID);
void listenToChannelPointRewards(const QString &channelID);
std::vector<QString> requests;
struct {
std::atomic<uint32_t> connectionsClosed{0};
std::atomic<uint32_t> connectionsOpened{0};
std::atomic<uint32_t> connectionsFailed{0};
std::atomic<uint32_t> messagesReceived{0};
std::atomic<uint32_t> messagesFailedToParse{0};
std::atomic<uint32_t> failedListenResponses{0};
std::atomic<uint32_t> listenResponses{0};
std::atomic<uint32_t> unlistenResponses{0};
} diag;
void listenToTopic(const QString &topic);
private:
void listen(PubSubListenMessage msg);
bool tryListen(PubSubListenMessage msg);
bool isListeningToTopic(const QString &topic);
void addClient();
std::atomic<bool> addingClient{false};
ExponentialBackoff<5> connectBackoff{std::chrono::milliseconds(1000)};
State state = State::Connected;
std::map<WebsocketHandle, std::shared_ptr<PubSubClient>,
std::owner_less<WebsocketHandle>>
clients;
std::unordered_map<
QString, std::function<void(const QJsonObject &, const QString &)>>
moderationActionHandlers;
std::unordered_map<
QString, std::function<void(const QJsonObject &, const QString &)>>
channelTermsActionHandlers;
void onMessage(websocketpp::connection_hdl hdl, WebsocketMessagePtr msg);
void onConnectionOpen(websocketpp::connection_hdl hdl);
void onConnectionFail(websocketpp::connection_hdl hdl);
void onConnectionClose(websocketpp::connection_hdl hdl);
WebsocketContextPtr onTLSInit(websocketpp::connection_hdl hdl);
void handleResponse(const PubSubMessage &message);
void handleListenResponse(const NonceInfo &info, bool failed);
void handleUnlistenResponse(const NonceInfo &info, bool failed);
void handleMessageResponse(const PubSubMessageMessage &message);
// Register a nonce for a specific client
void registerNonce(QString nonce, NonceInfo nonceInfo);
// Find client associated with a nonce
boost::optional<NonceInfo> findNonceInfo(QString nonce);
std::unordered_map<QString, NonceInfo> nonces_;
void runThread();
std::shared_ptr<boost::asio::io_service::work> work{nullptr};
const QString host_;
const PubSubClientOptions clientOptions_;
bool stopping_{false};
};
} // namespace chatterino
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "providers/twitch/pubsubmessages/AutoMod.hpp"
#include "providers/twitch/pubsubmessages/Base.hpp"
#include "providers/twitch/pubsubmessages/ChannelPoints.hpp"
#include "providers/twitch/pubsubmessages/ChatModeratorAction.hpp"
#include "providers/twitch/pubsubmessages/Listen.hpp"
#include "providers/twitch/pubsubmessages/Message.hpp"
#include "providers/twitch/pubsubmessages/Unlisten.hpp"
#include "providers/twitch/pubsubmessages/Whisper.hpp"
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "providers/twitch/ChatterinoWebSocketppLogger.hpp"
#include <websocketpp/client.hpp>
#include <websocketpp/config/asio_client.hpp>
#include <websocketpp/extensions/permessage_deflate/disabled.hpp>
#include <websocketpp/logger/basic.hpp>
namespace chatterino {
struct chatterinoconfig : public websocketpp::config::asio_tls_client {
typedef websocketpp::log::chatterinowebsocketpplogger<
concurrency_type, websocketpp::log::elevel>
elog_type;
typedef websocketpp::log::chatterinowebsocketpplogger<
concurrency_type, websocketpp::log::alevel>
alog_type;
struct permessage_deflate_config {
};
typedef websocketpp::extensions::permessage_deflate::disabled<
permessage_deflate_config>
permessage_deflate_type;
};
using WebsocketClient = websocketpp::client<chatterinoconfig>;
using WebsocketHandle = websocketpp::connection_hdl;
using WebsocketErrorCode = websocketpp::lib::error_code;
} // namespace chatterino
-14
View File
@@ -1,14 +0,0 @@
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/PubsubHelpers.hpp"
namespace chatterino {
PubSubAction::PubSubAction(const rapidjson::Value &data, const QString &_roomID)
: timestamp(std::chrono::steady_clock::now())
, roomID(_roomID)
{
getCreatedByUser(data, this->source);
}
} // namespace chatterino
File diff suppressed because it is too large Load Diff
-209
View File
@@ -1,209 +0,0 @@
#pragma once
#include "providers/twitch/ChatterinoWebSocketppLogger.hpp"
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include <rapidjson/document.h>
#include <QString>
#include <pajlada/signals/signal.hpp>
#include <websocketpp/client.hpp>
#include <websocketpp/config/asio_client.hpp>
#include <websocketpp/extensions/permessage_deflate/disabled.hpp>
#include <websocketpp/logger/basic.hpp>
#include <atomic>
#include <chrono>
#include <map>
#include <memory>
#include <set>
#include <thread>
#include <unordered_map>
#include <vector>
namespace chatterino {
struct chatterinoconfig : public websocketpp::config::asio_tls_client {
typedef websocketpp::log::chatterinowebsocketpplogger<
concurrency_type, websocketpp::log::elevel>
elog_type;
typedef websocketpp::log::chatterinowebsocketpplogger<
concurrency_type, websocketpp::log::alevel>
alog_type;
struct permessage_deflate_config {
};
typedef websocketpp::extensions::permessage_deflate::disabled<
permessage_deflate_config>
permessage_deflate_type;
};
using WebsocketClient = websocketpp::client<chatterinoconfig>;
using WebsocketHandle = websocketpp::connection_hdl;
using WebsocketErrorCode = websocketpp::lib::error_code;
#define MAX_PUBSUB_LISTENS 50
#define MAX_PUBSUB_CONNECTIONS 10
struct RequestMessage {
QString payload;
int topicCount;
};
namespace detail {
struct Listener {
QString topic;
bool authed;
bool persistent;
bool confirmed = false;
};
class PubSubClient : public std::enable_shared_from_this<PubSubClient>
{
public:
PubSubClient(WebsocketClient &_websocketClient,
WebsocketHandle _handle);
void start();
void stop();
bool listen(rapidjson::Document &message);
void unlistenPrefix(const QString &prefix);
void handlePong();
bool isListeningToTopic(const QString &topic);
private:
void ping();
bool send(const char *payload);
WebsocketClient &websocketClient_;
WebsocketHandle handle_;
uint16_t numListens_ = 0;
std::vector<Listener> listeners_;
std::atomic<bool> awaitingPong_{false};
std::atomic<bool> started_{false};
};
} // namespace detail
class PubSub
{
using WebsocketMessagePtr =
websocketpp::config::asio_tls_client::message_type::ptr;
using WebsocketContextPtr =
websocketpp::lib::shared_ptr<boost::asio::ssl::context>;
template <typename T>
using Signal =
pajlada::Signals::Signal<T>; // type-id is vector<T, Alloc<T>>
WebsocketClient websocketClient;
std::unique_ptr<std::thread> mainThread;
public:
PubSub();
~PubSub() = delete;
enum class State {
Connected,
Disconnected,
};
void start();
bool isConnected() const
{
return this->state == State::Connected;
}
pajlada::Signals::NoArgSignal connected;
struct {
struct {
Signal<ClearChatAction> chatCleared;
Signal<DeleteAction> messageDeleted;
Signal<ModeChangedAction> modeChanged;
Signal<ModerationStateAction> moderationStateChanged;
Signal<BanAction> userBanned;
Signal<UnbanAction> userUnbanned;
Signal<AutomodAction> automodMessage;
Signal<AutomodUserAction> automodUserMessage;
Signal<AutomodInfoAction> automodInfoMessage;
} moderation;
struct {
// Parsing should be done in PubSubManager as well,
// but for now we just send the raw data
Signal<const rapidjson::Value &> received;
Signal<const rapidjson::Value &> sent;
} whisper;
struct {
Signal<rapidjson::Value &> redeemed;
} pointReward;
} signals_;
void listenToWhispers(std::shared_ptr<TwitchAccount> account);
void unlistenAllModerationActions();
void listenToChannelModerationActions(
const QString &channelID, std::shared_ptr<TwitchAccount> account);
void listenToAutomod(const QString &channelID,
std::shared_ptr<TwitchAccount> account);
void listenToChannelPointRewards(const QString &channelID,
std::shared_ptr<TwitchAccount> account);
std::vector<std::unique_ptr<rapidjson::Document>> requests;
private:
void listenToTopic(const QString &topic,
std::shared_ptr<TwitchAccount> account);
void listen(rapidjson::Document &&msg);
bool tryListen(rapidjson::Document &msg);
bool isListeningToTopic(const QString &topic);
void addClient();
std::atomic<bool> addingClient{false};
State state = State::Connected;
std::map<WebsocketHandle, std::shared_ptr<detail::PubSubClient>,
std::owner_less<WebsocketHandle>>
clients;
std::unordered_map<
QString, std::function<void(const rapidjson::Value &, const QString &)>>
moderationActionHandlers;
std::unordered_map<
QString, std::function<void(const rapidjson::Value &, const QString &)>>
channelTermsActionHandlers;
void onMessage(websocketpp::connection_hdl hdl, WebsocketMessagePtr msg);
void onConnectionOpen(websocketpp::connection_hdl hdl);
void onConnectionClose(websocketpp::connection_hdl hdl);
WebsocketContextPtr onTLSInit(websocketpp::connection_hdl hdl);
void handleResponse(const rapidjson::Document &msg);
void handleListenResponse(const RequestMessage &msg, bool failed);
void handleUnlistenResponse(const RequestMessage &msg, bool failed);
void handleMessageResponse(const rapidjson::Value &data);
void runThread();
};
} // namespace chatterino
-104
View File
@@ -1,104 +0,0 @@
#include "providers/twitch/PubsubHelpers.hpp"
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "util/RapidjsonHelpers.hpp"
namespace chatterino {
const rapidjson::Value &getArgs(const rapidjson::Value &data)
{
if (!data.HasMember("args"))
{
throw std::runtime_error("Missing member args");
}
const auto &args = data["args"];
if (!args.IsArray())
{
throw std::runtime_error("args must be an array");
}
return args;
}
const rapidjson::Value &getMsgID(const rapidjson::Value &data)
{
if (!data.HasMember("msg_id"))
{
throw std::runtime_error("Missing member msg_id");
}
const auto &msgID = data["msg_id"];
return msgID;
}
bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user)
{
return rj::getSafe(data, "created_by", user.login) &&
rj::getSafe(data, "created_by_user_id", user.id);
}
bool getTargetUser(const rapidjson::Value &data, ActionUser &user)
{
return rj::getSafe(data, "target_user_id", user.id);
}
bool getTargetUserName(const rapidjson::Value &data, ActionUser &user)
{
return rj::getSafe(data, "target_user_login", user.login);
}
rapidjson::Document createListenMessage(const std::vector<QString> &topicsVec,
std::shared_ptr<TwitchAccount> account)
{
rapidjson::Document msg(rapidjson::kObjectType);
auto &a = msg.GetAllocator();
rj::set(msg, "type", "LISTEN");
rapidjson::Value data(rapidjson::kObjectType);
if (account)
{
rj::set(data, "auth_token", account->getOAuthToken(), a);
}
rapidjson::Value topics(rapidjson::kArrayType);
for (const auto &topic : topicsVec)
{
rj::add(topics, topic, a);
}
rj::set(data, "topics", topics, a);
rj::set(msg, "data", data);
return msg;
}
rapidjson::Document createUnlistenMessage(const std::vector<QString> &topicsVec)
{
rapidjson::Document msg(rapidjson::kObjectType);
auto &a = msg.GetAllocator();
rj::set(msg, "type", "UNLISTEN");
rapidjson::Value data(rapidjson::kObjectType);
rapidjson::Value topics(rapidjson::kArrayType);
for (const auto &topic : topicsVec)
{
rj::add(topics, topic, a);
}
rj::set(data, "topics", topics, a);
rj::set(msg, "data", data);
return msg;
}
} // namespace chatterino
@@ -119,7 +119,7 @@ void TwitchAccountManager::reloadUsers()
qCDebug(chatterinoTwitch)
<< "It was the current user, so we need to "
"reconnect stuff!";
this->currentUserChanged.invoke();
this->currentUserChanged();
}
}
break;
@@ -156,7 +156,7 @@ void TwitchAccountManager::load()
this->currentUser_ = this->anonymousUser_;
}
this->currentUserChanged.invoke();
this->currentUserChanged();
});
}
@@ -5,6 +5,8 @@
#include "providers/twitch/TwitchAccount.hpp"
#include "util/SharedPtrElementLess.hpp"
#include <boost/signals2.hpp>
#include <mutex>
#include <vector>
@@ -48,7 +50,8 @@ public:
pajlada::Settings::Setting<QString> currentUsername{"/accounts/current",
""};
pajlada::Signals::NoArgSignal currentUserChanged;
// pajlada::Signals::NoArgSignal currentUserChanged;
boost::signals2::signal<void()> currentUserChanged;
pajlada::Signals::NoArgSignal userListUpdated;
SignalVector<std::shared_ptr<TwitchAccount>> accounts;
+20 -23
View File
@@ -11,8 +11,9 @@
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/bttv/LoadBttvChannelEmote.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
#include "providers/twitch/PubsubClient.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "singletons/Emotes.hpp"
@@ -159,24 +160,20 @@ TwitchChannel::TwitchChannel(const QString &name)
{
qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened";
this->signalHolder_.managedConnect(
getApp()->accounts->twitch.currentUserChanged, [=] {
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([=] {
this->setMod(false);
});
this->refreshPubSub();
}));
// pubsub
this->signalHolder_.managedConnect(
getApp()->accounts->twitch.currentUserChanged, [=] {
this->refreshPubsub();
});
this->refreshPubsub();
this->refreshPubSub();
this->userStateChanged.connect([this] {
this->refreshPubsub();
this->refreshPubSub();
});
// room id loaded -> refresh live status
this->roomIdChanged.connect([this]() {
this->refreshPubsub();
this->refreshPubSub();
this->refreshTitle();
this->refreshLiveStatus();
this->refreshBadges();
@@ -281,11 +278,6 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward)
{
assertInGuiThread();
if (!reward.hasParsedSuccessfully)
{
return;
}
if (!reward.isUserInputRequired)
{
MessageBuilder builder;
@@ -295,7 +287,7 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward)
return;
}
bool result;
bool result = false;
{
auto channelPointRewards = this->channelPointRewards_.access();
result = channelPointRewards->try_emplace(reward.id, reward).second;
@@ -847,16 +839,21 @@ void TwitchChannel::loadRecentMessages()
.execute();
}
void TwitchChannel::refreshPubsub()
void TwitchChannel::refreshPubSub()
{
auto roomId = this->roomId();
if (roomId.isEmpty())
{
return;
}
auto account = getApp()->accounts->twitch.getCurrent();
getApp()->twitch->pubsub->listenToChannelModerationActions(roomId, account);
getApp()->twitch->pubsub->listenToAutomod(roomId, account);
getApp()->twitch->pubsub->listenToChannelPointRewards(roomId, account);
auto currentAccount = getApp()->accounts->twitch.getCurrent();
getApp()->twitch->pubsub->setAccount(currentAccount);
getApp()->twitch->pubsub->listenToChannelModerationActions(roomId);
getApp()->twitch->pubsub->listenToAutomod(roomId);
getApp()->twitch->pubsub->listenToChannelPointRewards(roomId);
}
void TwitchChannel::refreshChatters()
+3 -1
View File
@@ -16,6 +16,7 @@
#include <QElapsedTimer>
#include <QRegularExpression>
#include <boost/optional.hpp>
#include <boost/signals2.hpp>
#include <pajlada/signals/signalholder.hpp>
#include <mutex>
@@ -144,7 +145,7 @@ private:
// Methods
void refreshLiveStatus();
void parseLiveStatus(bool live, const HelixStream &stream);
void refreshPubsub();
void refreshPubSub();
void refreshChatters();
void refreshBadges();
void refreshCheerEmotes();
@@ -199,6 +200,7 @@ private:
bool isClipCreationInProgress{false};
pajlada::Signals::SignalHolder signalHolder_;
std::vector<boost::signals2::scoped_connection> bSignals_;
friend class TwitchIrcServer;
friend class TwitchMessageBuilder;
+5 -2
View File
@@ -11,7 +11,7 @@
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
#include "providers/twitch/PubsubClient.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchHelpers.hpp"
@@ -22,6 +22,8 @@
// using namespace Communi;
using namespace std::chrono_literals;
#define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv"
namespace chatterino {
TwitchIrcServer::TwitchIrcServer()
@@ -32,7 +34,7 @@ TwitchIrcServer::TwitchIrcServer()
{
this->initializeIrc();
this->pubsub = new PubSub;
this->pubsub = new PubSub(TWITCH_PUBSUB_URL);
// getSettings()->twitchSeperateWriteConnection.connect([this](auto, auto) {
// this->connect(); },
@@ -45,6 +47,7 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths)
getApp()->accounts->twitch.currentUserChanged.connect([this]() {
postToThread([this] {
this->connect();
this->pubsub->setAccount(getApp()->accounts->twitch.getCurrent());
});
});
@@ -4,7 +4,7 @@
#include "common/Outcome.hpp"
#include "messages/SharedMessageBuilder.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/PubsubActions.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "providers/twitch/TwitchBadge.hpp"
#include <IrcMessage>
@@ -0,0 +1,40 @@
#include "providers/twitch/pubsubmessages/AutoMod.hpp"
namespace chatterino {
PubSubAutoModQueueMessage::PubSubAutoModQueueMessage(const QJsonObject &root)
: typeString(root.value("type").toString())
, data(root.value("data").toObject())
, status(this->data.value("status").toString())
{
auto oType = magic_enum::enum_cast<Type>(this->typeString.toStdString());
if (oType.has_value())
{
this->type = oType.value();
}
auto contentClassification =
data.value("content_classification").toObject();
this->contentCategory = contentClassification.value("category").toString();
this->contentLevel = contentClassification.value("level").toInt();
auto message = data.value("message").toObject();
this->messageID = message.value("id").toString();
auto messageContent = message.value("content").toObject();
this->messageText = messageContent.value("text").toString();
auto messageSender = message.value("sender").toObject();
this->senderUserID = messageSender.value("user_id").toString();
this->senderUserLogin = messageSender.value("login").toString();
this->senderUserDisplayName =
messageSender.value("display_name").toString();
this->senderUserChatColor =
QColor(messageSender.value("chat_color").toString());
}
} // namespace chatterino
@@ -0,0 +1,53 @@
#pragma once
#include <QColor>
#include <QJsonObject>
#include <QString>
#include <magic_enum.hpp>
namespace chatterino {
struct PubSubAutoModQueueMessage {
enum class Type {
AutoModCaughtMessage,
INVALID,
};
QString typeString;
Type type = Type::INVALID;
QJsonObject data;
QString status;
QString contentCategory;
int contentLevel;
QString messageID;
QString messageText;
QString senderUserID;
QString senderUserLogin;
QString senderUserDisplayName;
QColor senderUserChatColor;
PubSubAutoModQueueMessage(const QJsonObject &root);
};
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name<
chatterino::PubSubAutoModQueueMessage::Type>(
chatterino::PubSubAutoModQueueMessage::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubAutoModQueueMessage::Type::AutoModCaughtMessage:
return "automod_caught_message";
default:
return default_tag;
}
}
@@ -0,0 +1,19 @@
#include "providers/twitch/pubsubmessages/Base.hpp"
namespace chatterino {
PubSubMessage::PubSubMessage(QJsonObject _object)
: object(std::move(_object))
, nonce(this->object.value("nonce").toString())
, error(this->object.value("error").toString())
, typeString(this->object.value("type").toString())
{
auto oType = magic_enum::enum_cast<Type>(this->typeString.toStdString());
if (oType.has_value())
{
this->type = oType.value();
}
}
} // namespace chatterino
@@ -0,0 +1,83 @@
#pragma once
#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
#include <magic_enum.hpp>
#include <boost/optional.hpp>
namespace chatterino {
struct PubSubMessage {
enum class Type {
Pong,
Response,
Message,
INVALID,
};
QJsonObject object;
QString nonce;
QString error;
QString typeString;
Type type;
PubSubMessage(QJsonObject _object);
template <class InnerClass>
boost::optional<InnerClass> toInner();
};
template <class InnerClass>
boost::optional<InnerClass> PubSubMessage::toInner()
{
auto dataValue = this->object.value("data");
if (!dataValue.isObject())
{
return boost::none;
}
auto data = dataValue.toObject();
return InnerClass{this->nonce, data};
}
static boost::optional<PubSubMessage> parsePubSubBaseMessage(
const QString &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
if (jsonDoc.isNull())
{
return boost::none;
}
return PubSubMessage(jsonDoc.object());
}
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t
magic_enum::customize::enum_name<chatterino::PubSubMessage::Type>(
chatterino::PubSubMessage::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubMessage::Type::Pong:
return "PONG";
case chatterino::PubSubMessage::Type::Response:
return "RESPONSE";
case chatterino::PubSubMessage::Type::Message:
return "MESSAGE";
default:
return default_tag;
}
}
@@ -0,0 +1,17 @@
#include "providers/twitch/pubsubmessages/ChannelPoints.hpp"
namespace chatterino {
PubSubCommunityPointsChannelV1Message::PubSubCommunityPointsChannelV1Message(
const QJsonObject &root)
: typeString(root.value("type").toString())
, data(root.value("data").toObject())
{
auto oType = magic_enum::enum_cast<Type>(this->typeString.toStdString());
if (oType.has_value())
{
this->type = oType.value();
}
}
} // namespace chatterino
@@ -0,0 +1,40 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <magic_enum.hpp>
namespace chatterino {
struct PubSubCommunityPointsChannelV1Message {
enum class Type {
RewardRedeemed,
INVALID,
};
QString typeString;
Type type = Type::INVALID;
QJsonObject data;
PubSubCommunityPointsChannelV1Message(const QJsonObject &root);
};
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name<
chatterino::PubSubCommunityPointsChannelV1Message::Type>(
chatterino::PubSubCommunityPointsChannelV1Message::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubCommunityPointsChannelV1Message::Type::
RewardRedeemed:
return "reward-redeemed";
default:
return default_tag;
}
}
@@ -0,0 +1,17 @@
#include "providers/twitch/pubsubmessages/ChatModeratorAction.hpp"
namespace chatterino {
PubSubChatModeratorActionMessage::PubSubChatModeratorActionMessage(
const QJsonObject &root)
: typeString(root.value("type").toString())
, data(root.value("data").toObject())
{
auto oType = magic_enum::enum_cast<Type>(this->typeString.toStdString());
if (oType.has_value())
{
this->type = oType.value();
}
}
} // namespace chatterino
@@ -0,0 +1,46 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <magic_enum.hpp>
namespace chatterino {
struct PubSubChatModeratorActionMessage {
enum class Type {
ModerationAction,
ChannelTermsAction,
INVALID,
};
QString typeString;
Type type = Type::INVALID;
QJsonObject data;
PubSubChatModeratorActionMessage(const QJsonObject &root);
};
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name<
chatterino::PubSubChatModeratorActionMessage::Type>(
chatterino::PubSubChatModeratorActionMessage::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubChatModeratorActionMessage::Type::
ModerationAction:
return "moderation_action";
case chatterino::PubSubChatModeratorActionMessage::Type::
ChannelTermsAction:
return "channel_terms_action";
default:
return default_tag;
}
}
@@ -0,0 +1,50 @@
#include "providers/twitch/pubsubmessages/Listen.hpp"
#include "util/Helpers.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace chatterino {
PubSubListenMessage::PubSubListenMessage(std::vector<QString> _topics)
: topics(std::move(_topics))
, nonce(generateUuid())
{
}
void PubSubListenMessage::setToken(const QString &_token)
{
this->token = _token;
}
QByteArray PubSubListenMessage::toJson() const
{
QJsonObject root;
root["type"] = "LISTEN";
root["nonce"] = this->nonce;
{
QJsonObject data;
QJsonArray jsonTopics;
std::copy(this->topics.begin(), this->topics.end(),
std::back_inserter(jsonTopics));
data["topics"] = jsonTopics;
if (!this->token.isEmpty())
{
data["auth_token"] = this->token;
}
root["data"] = data;
}
return QJsonDocument(root).toJson();
}
} // namespace chatterino
@@ -0,0 +1,24 @@
#pragma once
#include <QString>
#include <vector>
namespace chatterino {
// PubSubListenMessage is an outgoing LISTEN message that is sent for the client to subscribe to a list of topics
struct PubSubListenMessage {
const std::vector<QString> topics;
const QString nonce;
QString token;
PubSubListenMessage(std::vector<QString> _topics);
void setToken(const QString &_token);
QByteArray toJson() const;
};
} // namespace chatterino
@@ -0,0 +1,60 @@
#pragma once
#include "common/QLogging.hpp"
#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
#include <boost/optional.hpp>
namespace chatterino {
struct PubSubMessageMessage {
QString nonce;
QString topic;
QJsonObject messageObject;
PubSubMessageMessage(QString _nonce, const QJsonObject &data)
: nonce(std::move(_nonce))
, topic(data.value("topic").toString())
{
auto messagePayload = data.value("message").toString().toUtf8();
auto messageDoc = QJsonDocument::fromJson(messagePayload);
if (messageDoc.isNull())
{
qCWarning(chatterinoPubSub) << "PubSub message (type MESSAGE) "
"missing inner message payload";
return;
}
if (!messageDoc.isObject())
{
qCWarning(chatterinoPubSub)
<< "PubSub message (type MESSAGE) inner message payload is not "
"an object";
return;
}
this->messageObject = messageDoc.object();
}
template <class InnerClass>
boost::optional<InnerClass> toInner() const;
};
template <class InnerClass>
boost::optional<InnerClass> PubSubMessageMessage::toInner() const
{
if (this->messageObject.empty())
{
return boost::none;
}
return InnerClass{this->messageObject};
}
} // namespace chatterino
@@ -0,0 +1,40 @@
#include "providers/twitch/pubsubmessages/Unlisten.hpp"
#include "util/Helpers.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace chatterino {
PubSubUnlistenMessage::PubSubUnlistenMessage(std::vector<QString> _topics)
: topics(std::move(_topics))
, nonce(generateUuid())
{
}
QByteArray PubSubUnlistenMessage::toJson() const
{
QJsonObject root;
root["type"] = "UNLISTEN";
root["nonce"] = this->nonce;
{
QJsonObject data;
QJsonArray jsonTopics;
std::copy(this->topics.begin(), this->topics.end(),
std::back_inserter(jsonTopics));
data["topics"] = jsonTopics;
root["data"] = data;
}
return QJsonDocument(root).toJson();
}
} // namespace chatterino
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <vector>
namespace chatterino {
// PubSubUnlistenMessage is an outgoing UNLISTEN message that is sent for the client to unsubscribe from a list of topics
struct PubSubUnlistenMessage {
const std::vector<QString> topics;
const QString nonce;
PubSubUnlistenMessage(std::vector<QString> _topics);
QByteArray toJson() const;
};
} // namespace chatterino
@@ -0,0 +1,38 @@
#include "providers/twitch/pubsubmessages/Whisper.hpp"
namespace chatterino {
PubSubWhisperMessage::PubSubWhisperMessage(const QJsonObject &root)
: typeString(root.value("type").toString())
{
auto oType = magic_enum::enum_cast<Type>(this->typeString.toStdString());
if (oType.has_value())
{
this->type = oType.value();
}
// Parse information from data_object
auto data = root.value("data_object").toObject();
this->messageID = data.value("message_id").toString();
this->id = data.value("id").toInt();
this->threadID = data.value("thread_id").toString();
this->body = data.value("body").toString();
auto fromID = data.value("from_id");
if (fromID.isString())
{
this->fromUserID = fromID.toString();
}
else
{
this->fromUserID = QString::number(data.value("from_id").toInt());
}
auto tags = data.value("tags").toObject();
this->fromUserLogin = tags.value("login").toString();
this->fromUserDisplayName = tags.value("display_name").toString();
this->fromUserColor = QColor(tags.value("color").toString());
}
} // namespace chatterino
@@ -0,0 +1,55 @@
#pragma once
#include <QColor>
#include <QJsonObject>
#include <QString>
#include <magic_enum.hpp>
namespace chatterino {
struct PubSubWhisperMessage {
enum class Type {
WhisperReceived,
WhisperSent,
Thread,
INVALID,
};
QString typeString;
Type type = Type::INVALID;
QString messageID;
int id;
QString threadID;
QString body;
QString fromUserID;
QString fromUserLogin;
QString fromUserDisplayName;
QColor fromUserColor;
PubSubWhisperMessage(const QJsonObject &root);
};
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t
magic_enum::customize::enum_name<chatterino::PubSubWhisperMessage::Type>(
chatterino::PubSubWhisperMessage::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubWhisperMessage::Type::WhisperReceived:
return "whisper_received";
case chatterino::PubSubWhisperMessage::Type::WhisperSent:
return "whisper_sent";
case chatterino::PubSubWhisperMessage::Type::Thread:
return "thread";
default:
return default_tag;
}
}
+16 -8
View File
@@ -28,7 +28,8 @@
#ifndef NDEBUG
# include <rapidjson/document.h>
# include "providers/twitch/PubsubClient.hpp"
# include "providers/twitch/PubSubManager.hpp"
# include "providers/twitch/PubSubMessages.hpp"
# include "util/SampleCheerMessages.hpp"
# include "util/SampleLinks.hpp"
#endif
@@ -56,10 +57,10 @@ Window::Window(WindowType type)
this->addMenuBar();
#endif
this->signalHolder_.managedConnect(
getApp()->accounts->twitch.currentUserChanged, [this] {
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->onAccountSelected();
});
}));
this->onAccountSelected();
if (type == WindowType::Main)
@@ -284,17 +285,24 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions)
static bool alt = true;
if (alt)
{
doc.Parse(channelRewardMessage);
auto oMessage = parsePubSubBaseMessage(channelRewardMessage);
auto oInnerMessage =
oMessage->toInner<PubSubMessageMessage>()
->toInner<PubSubCommunityPointsChannelV1Message>();
app->twitch->addFakeMessage(channelRewardIRCMessage);
app->twitch->pubsub->signals_.pointReward.redeemed.invoke(
doc["data"]["message"]["data"]["redemption"]);
oInnerMessage->data.value("redemption").toObject());
alt = !alt;
}
else
{
doc.Parse(channelRewardMessage2);
auto oMessage = parsePubSubBaseMessage(channelRewardMessage2);
auto oInnerMessage =
oMessage->toInner<PubSubMessageMessage>()
->toInner<PubSubCommunityPointsChannelV1Message>();
app->twitch->pubsub->signals_.pointReward.redeemed.invoke(
doc["data"]["message"]["data"]["redemption"]);
oInnerMessage->data.value("redemption").toObject());
alt = !alt;
}
return "";
+2
View File
@@ -2,6 +2,7 @@
#include "widgets/BaseWindow.hpp"
#include <boost/signals2.hpp>
#include <pajlada/settings/setting.hpp>
#include <pajlada/signals/signal.hpp>
#include <pajlada/signals/signalholder.hpp>
@@ -48,6 +49,7 @@ private:
std::shared_ptr<UpdateDialog> updateDialogHandle_;
pajlada::Signals::SignalHolder signalHolder_;
std::vector<boost::signals2::scoped_connection> bSignals_;
friend class Notebook;
};
+3
View File
@@ -167,6 +167,9 @@ AboutPage::AboutPage()
addLicense(form.getElement(), "lrucache",
"https://github.com/lamerman/cpp-lru-cache",
":/licenses/lrucache.txt");
addLicense(form.getElement(), "magic_enum",
"https://github.com/Neargye/magic_enum",
":/licenses/magic_enum.txt");
}
// Attributions
+3 -3
View File
@@ -103,10 +103,10 @@ Split::Split(QWidget *parent)
this->input_->ui_.textEdit->installEventFilter(parent);
// update placeholder text on Twitch account change and channel change
this->signalHolder_.managedConnect(
getApp()->accounts->twitch.currentUserChanged, [this] {
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->updateInputPlaceholder();
});
}));
this->signalHolder_.managedConnect(channelChanged, [this] {
this->updateInputPlaceholder();
});
+2
View File
@@ -10,6 +10,7 @@
#include <QShortcut>
#include <QVBoxLayout>
#include <QWidget>
#include <boost/signals2.hpp>
namespace chatterino {
@@ -151,6 +152,7 @@ private:
pajlada::Signals::Connection indirectChannelChangedConnection_;
pajlada::Signals::SignalHolder signalHolder_;
std::vector<boost::signals2::scoped_connection> bSignals_;
public slots:
void addSibling();
+3 -3
View File
@@ -204,10 +204,10 @@ SplitHeader::SplitHeader(Split *_split)
this->handleChannelChanged();
});
this->managedConnections_.managedConnect(
getApp()->accounts->twitch.currentUserChanged, [this] {
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->updateModerationModeIcon();
});
}));
auto _ = [this](const auto &, const auto &) {
this->updateChannelText();
+5 -3
View File
@@ -2,15 +2,16 @@
#include "widgets/BaseWidget.hpp"
#include <QElapsedTimer>
#include <QMenu>
#include <QPoint>
#include <memory>
#include <boost/signals2.hpp>
#include <pajlada/settings/setting.hpp>
#include <pajlada/signals/connection.hpp>
#include <pajlada/signals/signalholder.hpp>
#include <vector>
#include <QElapsedTimer>
#include <memory>
#include <vector>
namespace chatterino {
@@ -85,6 +86,7 @@ private:
pajlada::Signals::NoArgSignal modeUpdateRequested_;
pajlada::Signals::SignalHolder managedConnections_;
pajlada::Signals::SignalHolder channelConnections_;
std::vector<boost::signals2::scoped_connection> bSignals_;
public slots:
void reloadChannelEmotes();