refactor: move Twitch PubSub to use liveupdates (#6638)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix
2025-12-16 11:06:15 +01:00
committed by GitHub
parent b01cf070b5
commit 7d4ea79376
31 changed files with 353 additions and 1463 deletions
-1
View File
@@ -291,7 +291,6 @@ void Application::initialize(Settings &settings, const Paths &paths)
{
this->initNm(paths);
}
this->twitchPubSub->initialize();
this->twitch->initEventAPIs(this->bttvLiveUpdates.get(),
this->seventvEventAPI.get());
-5
View File
@@ -433,12 +433,9 @@ set(SOURCE_FILES
providers/twitch/IrcMessageHandler.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
@@ -886,7 +883,6 @@ if (CHATTERINO_GENERATE_COVERAGE)
EXCLUDE "lib/settings/*"
EXCLUDE "lib/signals/*"
EXCLUDE "lib/sol2/*"
EXCLUDE "lib/websocketpp/*"
EXCLUDE "lib/WinToast/*"
EXCLUDE "*/ui_*.h"
@@ -918,7 +914,6 @@ target_link_libraries(${LIBRARY_PROJECT}
Pajlada::Serialize
Pajlada::Settings
Pajlada::Signals
websocketpp::websocketpp
Threads::Threads
LRUCache
MagicEnum
@@ -1,11 +1,6 @@
#pragma once
#include "common/QLogging.hpp"
#include <QNetworkProxy>
#include <websocketpp/error.hpp>
#include <string>
namespace chatterino {
@@ -24,36 +19,6 @@ public:
* Currently a proxy is applied if configured.
*/
static void applyFromEnv(const Env &env);
static void applyToWebSocket(const auto &connection)
{
const auto applicationProxy = QNetworkProxy::applicationProxy();
if (applicationProxy.type() != QNetworkProxy::HttpProxy)
{
return;
}
std::string url = "http://";
url += applicationProxy.hostName().toStdString();
url += ":";
url += std::to_string(applicationProxy.port());
websocketpp::lib::error_code ec;
connection->set_proxy(url, ec);
if (ec)
{
qCDebug(chatterinoNetwork)
<< "Couldn't set websocket proxy:" << ec.value();
return;
}
connection->set_proxy_basic_auth(
applicationProxy.user().toStdString(),
applicationProxy.password().toStdString(), ec);
if (ec)
{
qCDebug(chatterinoNetwork)
<< "Couldn't set websocket proxy auth:" << ec.value();
}
}
};
} // namespace chatterino
@@ -13,7 +13,7 @@
namespace chatterino {
BttvLiveUpdateClient::BttvLiveUpdateClient(BttvLiveUpdates &manager)
: BasicPubSubClient<BttvLiveUpdateSubscription>(100)
: BasicPubSubClient(100)
, manager(manager)
{
}
@@ -8,7 +8,7 @@ namespace chatterino {
class BttvLiveUpdates;
class BttvLiveUpdateClient
: public BasicPubSubClient<BttvLiveUpdateSubscription>
: public BasicPubSubClient<BttvLiveUpdateSubscription, BttvLiveUpdateClient>
{
public:
BttvLiveUpdateClient(BttvLiveUpdates &manager);
@@ -20,7 +20,7 @@ namespace chatterino {
*
* @tparam Subscription see BasicPubSubManager
*/
template <typename SubscriptionT>
template <typename SubscriptionT, typename Derived>
class BasicPubSubClient
{
public:
@@ -66,6 +66,16 @@ public:
this->ws_.sendText(data);
}
QByteArray encodeSubscription(const Subscription &subscription)
{
return subscription.encodeSubscribe();
}
QByteArray encodeUnsubscription(const Subscription &subscription)
{
return subscription.encodeUnsubscribe();
}
protected:
/**
* @return true if this client subscribed to this subscription
@@ -93,7 +103,8 @@ protected:
qCDebug(chatterinoLiveupdates) << "Subscribing to" << subscription;
DebugCount::increase("LiveUpdates subscriptions");
QByteArray encoded = subscription.encodeSubscribe();
QByteArray encoded =
static_cast<Derived *>(this)->encodeSubscription(subscription);
this->ws_.sendText(encoded);
return true;
@@ -113,7 +124,8 @@ protected:
qCDebug(chatterinoLiveupdates) << "Unsubscribing from" << subscription;
DebugCount::decrease("LiveUpdates subscriptions");
QByteArray encoded = subscription.encodeUnsubscribe();
QByteArray encoded =
static_cast<Derived *>(this)->encodeUnsubscription(subscription);
this->ws_.sendText(encoded);
return true;
+2 -2
View File
@@ -14,7 +14,7 @@ namespace chatterino::seventv::eventapi {
Client::Client(SeventvEventAPI &manager,
std::chrono::milliseconds heartbeatInterval)
: BasicPubSubClient<Subscription>(100)
: BasicPubSubClient(100)
, lastHeartbeat_(std::chrono::steady_clock::now())
, heartbeatInterval_(heartbeatInterval)
, manager_(manager)
@@ -23,7 +23,7 @@ Client::Client(SeventvEventAPI &manager,
void Client::onOpen()
{
BasicPubSubClient<Subscription>::onOpen();
BasicPubSubClient::onOpen();
this->lastHeartbeat_.store(std::chrono::steady_clock::now(),
std::memory_order::relaxed);
}
+2 -2
View File
@@ -18,8 +18,8 @@ struct Dispatch;
struct CosmeticCreateDispatch;
struct EntitlementCreateDeleteDispatch;
class Client : public BasicPubSubClient<Subscription>,
std::enable_shared_from_this<Client>
class Client : public BasicPubSubClient<Subscription, Client>,
public std::enable_shared_from_this<Client>
{
public:
Client(SeventvEventAPI &manager,
@@ -1,194 +0,0 @@
/*
* Copyright (c) 2020, Peter Thorson, Steve Wills. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the name of the WebSocket++ Project nor the
* names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL PETER THORSON BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#pragma once
#include "common/QLogging.hpp"
#include <websocketpp/common/cpp11.hpp>
#include <websocketpp/logger/basic.hpp>
#include <websocketpp/logger/levels.hpp>
#include <string>
namespace websocketpp::log {
template <typename concurrency, typename names>
class chatterinowebsocketpplogger : public basic<concurrency, names>
{
public:
using base = chatterinowebsocketpplogger<concurrency, names>;
chatterinowebsocketpplogger(channel_type_hint::value)
: m_static_channels(0xffffffff)
, m_dynamic_channels(0)
{
}
chatterinowebsocketpplogger(std::ostream *)
: m_static_channels(0xffffffff)
, m_dynamic_channels(0)
{
}
chatterinowebsocketpplogger(level c, channel_type_hint::value)
: m_static_channels(c)
, m_dynamic_channels(0)
{
}
chatterinowebsocketpplogger(level c, std::ostream *)
: m_static_channels(c)
, m_dynamic_channels(0)
{
}
~chatterinowebsocketpplogger()
{
}
chatterinowebsocketpplogger(
chatterinowebsocketpplogger<concurrency, names> const &other)
: m_static_channels(other.m_static_channels)
, m_dynamic_channels(other.m_dynamic_channels)
{
}
#ifdef _WEBSOCKETPP_DEFAULT_DELETE_FUNCTIONS_
chatterinowebsocketpplogger<concurrency, names> &operator=(
chatterinowebsocketpplogger<concurrency, names> const &) = delete;
#endif // _WEBSOCKETPP_DEFAULT_DELETE_FUNCTIONS_
#ifdef _WEBSOCKETPP_MOVE_SEMANTICS_
/// Move constructor
chatterinowebsocketpplogger(
chatterinowebsocketpplogger<concurrency, names> &&other)
: m_static_channels(other.m_static_channels)
, m_dynamic_channels(other.m_dynamic_channels)
{
}
# ifdef _WEBSOCKETPP_DEFAULT_DELETE_FUNCTIONS_
// no move assignment operator because of const member variables
chatterinowebsocketpplogger<concurrency, names> &operator=(
chatterinowebsocketpplogger<concurrency, names> &&) = delete;
# endif // _WEBSOCKETPP_DEFAULT_DELETE_FUNCTIONS_
#endif // _WEBSOCKETPP_MOVE_SEMANTICS_
/// Explicitly do nothing, this logger doesn't support changing ostream
void set_ostream(std::ostream *)
{
}
/// Dynamically enable the given list of channels
/**
* @param channels The package of channels to enable
*/
void set_channels(level channels)
{
if (channels == names::none)
{
clear_channels(names::all);
return;
}
scoped_lock_type lock(m_lock);
m_dynamic_channels |= (channels & m_static_channels);
}
/// Dynamically disable the given list of channels
/**
* @param channels The package of channels to disable
*/
void clear_channels(level channels)
{
scoped_lock_type lock(m_lock);
m_dynamic_channels &= ~channels;
}
/// Write a string message to the given channel
/**
* @param channel The channel to write to
* @param msg The message to write
*/
void write(level channel, std::string const &msg)
{
scoped_lock_type lock(m_lock);
if (!this->dynamic_test(channel))
{
return;
}
qCDebug(chatterinoWebsocket).nospace()
<< names::channel_name(channel) << ": "
<< QString::fromStdString(msg);
}
/// Write a cstring message to the given channel
/**
* @param channel The channel to write to
* @param msg The message to write
*/
void write(level channel, char const *msg)
{
scoped_lock_type lock(m_lock);
if (!this->dynamic_test(channel))
{
return;
}
qCDebug(chatterinoWebsocket).nospace()
<< names::channel_name(channel) << ": " << msg;
}
/// Test whether a channel is statically enabled
/**
* @param channel The package of channels to test
*/
_WEBSOCKETPP_CONSTEXPR_TOKEN_ bool static_test(level channel) const
{
return ((channel & m_static_channels) != 0);
}
/// Test whether a channel is dynamically enabled
/**
* @param channel The package of channels to test
*/
bool dynamic_test(level channel)
{
return ((channel & m_dynamic_channels) != 0);
}
protected:
using scoped_lock_type = typename concurrency::scoped_lock_type;
using mutex_type = typename concurrency::mutex_type;
mutex_type m_lock;
private:
level const m_static_channels;
level m_dynamic_channels;
};
} // namespace websocketpp::log
+140 -168
View File
@@ -1,224 +1,196 @@
#include "providers/twitch/PubSubClient.hpp"
#include "common/QLogging.hpp"
#include "providers/twitch/PubSubHelpers.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/PubSubMessages.hpp"
#include "util/DebugCount.hpp"
namespace chatterino {
static const char *PING_PAYLOAD = R"({"type":"PING"})";
using namespace Qt::Literals;
PubSubClient::PubSubClient(WebsocketClient &websocketClient,
WebsocketHandle handle,
const PubSubClientOptions &clientOptions)
: websocketClient_(websocketClient)
, handle_(handle)
, heartbeatTimer_(std::make_shared<boost::asio::steady_timer>(
this->websocketClient_.get_io_service()))
, clientOptions_(clientOptions)
QDebug operator<<(QDebug debug, const TopicData &data)
{
QDebugStateSaver saver(debug);
debug.nospace() << "TopicData(" << data.topic << ')';
return debug;
}
PubSubClient::PubSubClient(PubSub &manager,
std::chrono::milliseconds heartbeatInterval)
: BasicPubSubClient(MAX_LISTENS)
, heartbeatInterval_(heartbeatInterval)
, manager_(manager)
{
}
void PubSubClient::start()
void PubSubClient::onOpen()
{
assert(!this->started_);
this->started_ = true;
this->ping();
BasicPubSubClient::onOpen();
this->isOpen_ = true;
this->lastHeartbeat_ = std::chrono::steady_clock::now();
}
void PubSubClient::stop()
void PubSubClient::onMessage(const QByteArray &msg)
{
assert(this->started_);
this->manager_.diag.messagesReceived++;
this->started_ = false;
this->heartbeatTimer_->cancel();
}
auto optMessage = parsePubSubBaseMessage(msg);
if (!optMessage)
{
qCDebug(chatterinoPubSub)
<< "Unable to parse incoming pubsub message" << msg;
this->manager_.diag.messagesFailedToParse += 1;
return;
}
void PubSubClient::close(const std::string &reason,
websocketpp::close::status::value code)
{
boost::asio::post(
this->websocketClient_.get_io_service().get_executor(),
[this, reason, code] {
// We need to post this request to the io service executor
// to ensure the weak pointer used in get_con_from_hdl is used in a safe way
WebsocketErrorCode ec;
auto message = *optMessage;
auto conn =
this->websocketClient_.get_con_from_hdl(this->handle_, ec);
if (ec)
switch (message.type)
{
case PubSubMessage::Type::Pong: {
this->lastHeartbeat_.store(std::chrono::steady_clock::now());
}
break;
case PubSubMessage::Type::Response: {
this->handleResponse(message);
}
break;
case PubSubMessage::Type::Message: {
auto oMessageMessage = message.toInner<PubSubMessageMessage>();
if (!oMessageMessage)
{
qCDebug(chatterinoPubSub)
<< "Error getting con:" << ec.message().c_str();
qCDebug(chatterinoPubSub) << "Malformed MESSAGE:" << msg;
return;
}
conn->close(code, reason, ec);
if (ec)
{
qCDebug(chatterinoPubSub)
<< "Error closing:" << ec.message().c_str();
return;
}
});
this->handleMessageResponse(*oMessageMessage);
}
break;
case PubSubMessage::Type::INVALID:
default: {
qCDebug(chatterinoPubSub)
<< "Unknown message type:" << message.typeString;
}
break;
}
}
bool PubSubClient::listen(const PubSubListenMessage &msg)
void PubSubClient::checkHeartbeat()
{
auto numRequestedListens = msg.topics.size();
if (this->numListens_ + numRequestedListens > PubSubClient::MAX_LISTENS)
if (!this->isOpen_)
{
// This PubSubClient is already at its peak listens
return false;
}
this->numListens_ += numRequestedListens;
DebugCount::increase("PubSub topic pending listens",
static_cast<int64_t>(numRequestedListens));
for (const auto &topic : msg.topics)
{
this->listeners_.emplace_back(Listener{
TopicData{
topic,
false,
false,
},
false,
});
return;
}
qCDebug(chatterinoPubSub)
<< "Subscribing to" << numRequestedListens << "topics";
auto dur = std::chrono::steady_clock::now() - this->lastHeartbeat_.load();
if (dur > this->heartbeatInterval_ * 1.5)
{
qCDebug(chatterinoPubSub) << "Heartbeat timed out";
this->close();
}
this->send(msg.toJson());
return true;
this->sendText(R"({"type":"PING"})"_ba);
}
PubSubClient::UnlistenPrefixResponse PubSubClient::unlistenPrefix(
const QString &prefix)
QByteArray PubSubClient::encodeSubscription(const Subscription &subscription)
{
std::vector<QString> topics;
PubSubListenMessage listen({subscription.topic});
this->nonces_[listen.nonce] = NonceInfo{
.isListen = true,
};
return listen.toJson();
}
for (auto it = this->listeners_.begin(); it != this->listeners_.end();)
QByteArray PubSubClient::encodeUnsubscription(const Subscription &subscription)
{
PubSubUnlistenMessage unlisten({subscription.topic});
this->nonces_[unlisten.nonce] = NonceInfo{
.isListen = true,
};
return unlisten.toJson();
}
void PubSubClient::handleResponse(const PubSubMessage &message)
{
const bool failed = !message.error.isEmpty();
if (failed)
{
const auto &listener = *it;
if (listener.topic.startsWith(prefix))
qCDebug(chatterinoPubSub)
<< "Error" << message.error << "on nonce" << message.nonce;
}
if (message.nonce.isEmpty())
{
// Can't do any specific handling since no nonce was specified
return;
}
auto nonceInfoIt = this->nonces_.find(message.nonce);
if (nonceInfoIt == this->nonces_.end())
{
qCDebug(chatterinoPubSub) << "Unknown nonce:" << message.nonce;
return;
}
if (nonceInfoIt->second.isListen)
{
if (failed)
{
topics.push_back(listener.topic);
it = this->listeners_.erase(it);
this->manager_.diag.failedListenResponses++;
}
else
{
++it;
this->manager_.diag.listenResponses++;
}
}
if (topics.empty())
else
{
return {{}, ""};
this->manager_.diag.unlistenResponses++;
}
auto numRequestedUnlistens = topics.size();
this->numListens_ -= numRequestedUnlistens;
DebugCount::increase("PubSub topic pending unlistens",
static_cast<int64_t>(numRequestedUnlistens));
PubSubUnlistenMessage message(topics);
this->send(message.toJson());
return {message.topics, message.nonce};
this->nonces_.erase(nonceInfoIt);
}
void PubSubClient::handleListenResponse(const PubSubMessage &message)
void PubSubClient::handleMessageResponse(const PubSubMessageMessage &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))
if (!message.topic.startsWith("community-points-channel-v1."))
{
return;
}
this->awaitingPong_ = true;
auto self = this->shared_from_this();
runAfter(this->heartbeatTimer_, this->clientOptions_.pingInterval_,
[self](auto timer) {
(void)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)
auto oInnerMessage =
message.toInner<PubSubCommunityPointsChannelV1Message>();
if (!oInnerMessage)
{
qCDebug(chatterinoPubSub) << "Error sending message" << payload << ":"
<< ec.message().c_str();
// TODO(pajlada): Check which error code happened and maybe
// gracefully handle it
return false;
qCDebug(chatterinoPubSub)
<< "Malformed community-points-channel-v1 message";
return;
}
return true;
const auto &innerMessage = *oInnerMessage;
switch (innerMessage.type)
{
case PubSubCommunityPointsChannelV1Message::Type::
AutomaticRewardRedeemed:
case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: {
auto redemption = innerMessage.data.value("redemption").toObject();
this->manager_.pointReward.redeemed.invoke(redemption);
}
break;
case PubSubCommunityPointsChannelV1Message::Type::INVALID:
default: {
qCDebug(chatterinoPubSub)
<< "Invalid point event type:" << innerMessage.typeString;
}
break;
}
}
} // namespace chatterino
+46 -42
View File
@@ -1,7 +1,7 @@
#pragma once
#include "providers/twitch/PubSubClientOptions.hpp"
#include "providers/twitch/PubSubWebsocket.hpp"
#include "providers/liveupdates/BasicPubSubClient.hpp"
#include "providers/twitch/pubsubmessages/Message.hpp"
#include <pajlada/signals/signal.hpp>
#include <QString>
@@ -11,67 +11,71 @@
namespace chatterino {
struct PubSubMessage;
struct PubSubListenMessage;
struct TopicData {
QString topic;
bool authed{false};
bool persistent{false};
bool operator==(const TopicData &other) const
{
return this->topic == other.topic;
}
friend QDebug operator<<(QDebug debug, const TopicData &data);
};
struct Listener : TopicData {
bool confirmed{false};
} // namespace chatterino
template <>
struct std::hash<chatterino::TopicData> {
std::size_t operator()(const chatterino::TopicData &data) const noexcept
{
return std::hash<QString>{}(data.topic);
}
};
class PubSubClient : public std::enable_shared_from_this<PubSubClient>
namespace chatterino {
struct PubSubMessage;
class PubSub;
struct PubSubListenMessage;
class PubSubClient : public BasicPubSubClient<TopicData, PubSubClient>
{
public:
// The max amount of topics we may listen to with a single connection
static constexpr size_t MAX_LISTENS = 50;
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(PubSub &manager, std::chrono::milliseconds heartbeatInterval);
PubSubClient(WebsocketClient &_websocketClient, WebsocketHandle _handle,
const PubSubClientOptions &clientOptions);
void onOpen() /* override */;
void onMessage(const QByteArray &msg) /* override */;
void start();
void stop();
void checkHeartbeat();
void close(const std::string &reason,
websocketpp::close::status::value code =
websocketpp::close::status::normal);
bool listen(const 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;
QByteArray encodeSubscription(
const Subscription &subscription) /* override */;
QByteArray encodeUnsubscription(
const Subscription &subscription) /* override */;
private:
void ping();
bool send(const char *payload);
struct NonceInfo {
bool isListen;
};
WebsocketClient &websocketClient_;
WebsocketHandle handle_;
uint16_t numListens_ = 0;
void handleResponse(const PubSubMessage &message);
void handleMessageResponse(const PubSubMessageMessage &message);
std::vector<Listener> listeners_;
std::unordered_map<QString, NonceInfo> nonces_;
std::atomic<bool> awaitingPong_{false};
std::atomic<bool> started_{false};
std::shared_ptr<boost::asio::steady_timer> heartbeatTimer_;
const PubSubClientOptions &clientOptions_;
std::atomic<std::chrono::time_point<std::chrono::steady_clock>>
lastHeartbeat_;
std::chrono::milliseconds heartbeatInterval_;
bool isOpen_ = false;
PubSub &manager_;
};
} // namespace chatterino
@@ -1,14 +0,0 @@
#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
-53
View File
@@ -1,53 +0,0 @@
#pragma once
#include "common/QLogging.hpp"
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>
#include <memory>
namespace chatterino {
class TwitchAccount;
struct ActionUser;
// Create timer using given ioService
template <typename Duration, typename Callback>
void runAfter(boost::asio::io_context &ioc, Duration duration, Callback cb)
{
auto timer = std::make_shared<boost::asio::steady_timer>(ioc);
timer->expires_after(duration);
timer->async_wait([timer, cb](const boost::system::error_code &ec) {
if (ec)
{
qCDebug(chatterinoPubSub)
<< "Error in runAfter:" << ec.message().c_str();
return;
}
cb(timer);
});
}
// Use provided timer
template <typename Duration, typename Callback>
void runAfter(std::shared_ptr<boost::asio::steady_timer> timer,
Duration duration, Callback cb)
{
timer->expires_after(duration);
timer->async_wait([timer, cb](const boost::system::error_code &ec) {
if (ec)
{
qCDebug(chatterinoPubSub)
<< "Error in runAfter:" << ec.message().c_str();
return;
}
cb(timer);
});
}
} // namespace chatterino
+52 -529
View File
@@ -2,148 +2,89 @@
#include "Application.hpp"
#include "common/QLogging.hpp"
#include "providers/NetworkConfigurationProvider.hpp"
#include "providers/liveupdates/BasicPubSubManager.hpp"
#include "providers/twitch/PubSubClient.hpp"
#include "providers/twitch/PubSubHelpers.hpp"
#include "providers/twitch/PubSubMessages.hpp"
#include "util/DebugCount.hpp"
#include "util/RenameThread.hpp"
#include <QJsonArray>
#include <QScopeGuard>
#include <algorithm>
#include <exception>
#include <memory>
#include <thread>
#include <utility>
using websocketpp::lib::bind;
using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using namespace std::chrono_literals;
namespace chatterino {
PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
: host_(host)
, clientOptions_({
pingInterval,
})
class PubSubManagerPrivate
: public BasicPubSubManager<PubSubManagerPrivate, PubSubClient>
{
this->websocketClient.set_access_channels(websocketpp::log::alevel::all);
this->websocketClient.clear_access_channels(
websocketpp::log::alevel::frame_payload |
websocketpp::log::alevel::frame_header);
public:
PubSubManagerPrivate(PubSub &parent, QString host,
std::chrono::milliseconds heartbeatInterval);
~PubSubManagerPrivate() override;
PubSubManagerPrivate(const PubSubManagerPrivate &) = delete;
PubSubManagerPrivate(PubSubManagerPrivate &&) = delete;
PubSubManagerPrivate &operator=(const PubSubManagerPrivate &) = delete;
PubSubManagerPrivate &operator=(PubSubManagerPrivate &&) = delete;
this->websocketClient.init_asio();
std::shared_ptr<PubSubClient> makeClient();
void checkHeartbeats();
// SSL Handshake
this->websocketClient.set_tls_init_handler(
bind(&PubSub::onTLSInit, this, ::_1));
std::chrono::milliseconds heartbeatInterval;
QTimer heartbeatTimer;
this->websocketClient.set_message_handler(
bind(&PubSub::onMessage, this, ::_1, ::_2));
this->websocketClient.set_open_handler(
bind(&PubSub::onConnectionOpen, this, ::_1));
this->websocketClient.set_close_handler(
bind(&PubSub::onConnectionClose, this, ::_1));
this->websocketClient.set_fail_handler(
bind(&PubSub::onConnectionFail, this, ::_1));
PubSub &parent;
friend BasicPubSubManager<PubSubManagerPrivate, PubSubClient>;
friend PubSub;
};
PubSubManagerPrivate::PubSubManagerPrivate(
PubSub &parent, QString host, std::chrono::milliseconds heartbeatInterval)
: BasicPubSubManager(std::move(host), "PubSub")
, heartbeatInterval(heartbeatInterval)
, parent(parent)
{
QObject::connect(&this->heartbeatTimer, &QTimer::timeout, this,
&PubSubManagerPrivate::checkHeartbeats);
this->heartbeatTimer.setInterval(this->heartbeatInterval);
this->heartbeatTimer.setSingleShot(false);
this->heartbeatTimer.start();
}
PubSub::~PubSub()
PubSubManagerPrivate::~PubSubManagerPrivate()
{
this->stop();
}
void PubSub::initialize()
void PubSubManagerPrivate::checkHeartbeats()
{
this->start();
for (const auto &[id, client] : this->clients())
{
client->checkHeartbeat();
}
}
void PubSub::addClient()
std::shared_ptr<PubSubClient> PubSubManagerPrivate::makeClient()
{
if (this->addingClient)
{
return;
}
qCDebug(chatterinoPubSub) << "Adding an additional client";
this->addingClient = true;
websocketpp::lib::error_code ec;
auto con =
this->websocketClient.get_connection(this->host_.toStdString(), ec);
if (ec)
{
qCDebug(chatterinoPubSub)
<< "Unable to establish connection:" << ec.message().c_str();
return;
}
NetworkConfigurationProvider::applyToWebSocket(con);
this->websocketClient.connect(con);
return std::make_shared<PubSubClient>(this->parent,
this->heartbeatInterval);
}
void PubSub::start()
PubSub::PubSub(const QString &host, std::chrono::seconds heartbeatInterval)
: private_(new PubSubManagerPrivate(*this, host, heartbeatInterval))
{
this->work = std::make_shared<boost::asio::executor_work_guard<
boost::asio::io_context::executor_type>>(
this->websocketClient.get_io_service().get_executor());
this->thread = std::make_unique<std::thread>([this] {
// make sure we set in any case, even exceptions
auto guard = qScopeGuard([&] {
this->stoppedFlag_.set();
});
}
runThread();
});
renameThread(*this->thread, "PubSub");
PubSub::~PubSub() = default;
const liveupdates::Diag &PubSub::wsDiag() const
{
return this->private_->diag;
}
void PubSub::stop()
{
this->stopping_ = true;
for (const auto &[hdl, client] : this->clients)
{
(void)hdl;
client->close("Shutting down");
}
this->work.reset();
if (!this->thread->joinable())
{
return;
}
// NOTE:
// There is a case where a new client was initiated but not added to the clients list.
// We just don't join the thread & let the operating system nuke the thread if joining fails
// within 1s.
// We could fix the underlying bug, but this is easier & we realistically won't use this exact code
// for super much longer.
if (this->stoppedFlag_.waitFor(std::chrono::milliseconds{100}))
{
this->thread->join();
return;
}
qCWarning(chatterinoLiveupdates)
<< "Thread didn't finish within 100ms, force-stop the client";
this->websocketClient.stop();
if (this->stoppedFlag_.waitFor(std::chrono::milliseconds{20}))
{
this->thread->join();
return;
}
qCWarning(chatterinoLiveupdates) << "Thread didn't finish after stopping";
this->private_->stop();
}
void PubSub::listenToChannelPointRewards(const QString &channelID)
@@ -153,426 +94,8 @@ void PubSub::listenToChannelPointRewards(const QString &channelID)
auto topic = topicFormat.arg(channelID);
if (this->isListeningToTopic(topic))
{
return;
}
qCDebug(chatterinoPubSub) << "Listen to topic" << topic;
this->listenToTopic(topic);
}
void PubSub::unlistenChannelPointRewards()
{
this->unlistenPrefix("community-points-channel-v1.");
}
void PubSub::unlistenPrefix(const QString &prefix)
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] = client->unlistenPrefix(prefix);
!topics.empty())
{
NonceInfo nonceInfo{
client,
"UNLISTEN",
topics,
topics.size(),
};
this->registerNonce(nonce, nonceInfo);
}
}
}
void PubSub::listen(PubSubListenMessage msg)
{
if (this->tryListen(msg))
{
return;
}
this->addClient();
std::copy(msg.topics.begin(), msg.topics.end(),
std::back_inserter(this->requests));
DebugCount::increase("PubSub topic backlog", msg.topics.size());
}
bool PubSub::tryListen(PubSubListenMessage msg)
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (auto success = client->listen(msg); success)
{
this->registerNonce(msg.nonce, {
client,
"LISTEN",
msg.topics,
msg.topics.size(),
});
return true;
}
}
return false;
}
void PubSub::registerNonce(QString nonce, NonceInfo info)
{
this->nonces_[nonce] = std::move(info);
}
std::optional<PubSub::NonceInfo> PubSub::findNonceInfo(QString nonce)
{
// TODO: This should also DELETE the nonceinfo from the map
auto it = this->nonces_.find(nonce);
if (it == this->nonces_.end())
{
return std::nullopt;
}
return it->second;
}
bool PubSub::isListeningToTopic(const QString &topic)
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (client->isListeningToTopic(topic))
{
return true;
}
}
return false;
}
void PubSub::onMessage(websocketpp::connection_hdl hdl,
WebsocketMessagePtr websocketMessage)
{
this->diag.messagesReceived += 1;
const auto &payload =
QString::fromStdString(websocketMessage->get_payload());
auto oMessage = parsePubSubBaseMessage(payload);
if (!oMessage)
{
qCDebug(chatterinoPubSub)
<< "Unable to parse incoming pubsub message" << payload;
this->diag.messagesFailedToParse += 1;
return;
}
auto message = *oMessage;
switch (message.type)
{
case PubSubMessage::Type::Pong: {
auto clientIt = this->clients.find(hdl);
// If this assert goes off, there's something wrong with the connection
// creation/preserving code KKona
assert(clientIt != this->clients.end());
auto &client = *clientIt;
client.second->handlePong();
}
break;
case PubSubMessage::Type::Response: {
this->handleResponse(message);
}
break;
case PubSubMessage::Type::Message: {
auto oMessageMessage = message.toInner<PubSubMessageMessage>();
if (!oMessageMessage)
{
qCDebug(chatterinoPubSub) << "Malformed MESSAGE:" << payload;
return;
}
this->handleMessageResponse(*oMessageMessage);
}
break;
case PubSubMessage::Type::INVALID:
default: {
qCDebug(chatterinoPubSub)
<< "Unknown message type:" << message.typeString;
}
break;
}
}
void PubSub::onConnectionOpen(WebsocketHandle hdl)
{
this->diag.connectionsOpened += 1;
DebugCount::increase("PubSub connections");
this->addingClient = false;
this->connectBackoff.reset();
auto client = std::make_shared<PubSubClient>(this->websocketClient, hdl,
this->clientOptions_);
// We separate the starting from the constructor because we will want to use
// shared_from_this
client->start();
this->clients.emplace(hdl, client);
qCDebug(chatterinoPubSub) << "PubSub connection opened!";
const auto topicsToTake =
std::min(this->requests.size(), PubSubClient::MAX_LISTENS);
std::vector<QString> newTopics(
std::make_move_iterator(this->requests.begin()),
std::make_move_iterator(this->requests.begin() + topicsToTake));
this->requests.erase(this->requests.begin(),
this->requests.begin() + topicsToTake);
PubSubListenMessage msg(newTopics);
if (auto success = client->listen(msg); !success)
{
qCWarning(chatterinoPubSub) << "Failed to listen to " << topicsToTake
<< "new topics on new client";
return;
}
DebugCount::decrease("PubSub topic backlog", msg.topics.size());
this->registerNonce(msg.nonce, {
client,
"LISTEN",
msg.topics,
topicsToTake,
});
if (!this->requests.empty())
{
this->addClient();
}
}
void PubSub::onConnectionFail(WebsocketHandle hdl)
{
this->diag.connectionsFailed += 1;
DebugCount::increase("PubSub failed connections");
if (auto conn = this->websocketClient.get_con_from_hdl(std::move(hdl)))
{
qCDebug(chatterinoPubSub) << "PubSub connection attempt failed (error: "
<< conn->get_ec().message().c_str() << ")";
}
else
{
qCDebug(chatterinoPubSub)
<< "PubSub connection attempt failed but we can't "
"get the connection from a handle.";
}
this->addingClient = false;
if (!this->requests.empty())
{
runAfter(this->websocketClient.get_io_service(),
this->connectBackoff.next(), [this](auto timer) {
this->addClient(); //
});
}
}
void PubSub::onConnectionClose(WebsocketHandle hdl)
{
qCDebug(chatterinoPubSub) << "Connection closed";
this->diag.connectionsClosed += 1;
DebugCount::decrease("PubSub connections");
auto clientIt = this->clients.find(hdl);
// If this assert goes off, there's something wrong with the connection
// creation/preserving code KKona
assert(clientIt != this->clients.end());
auto client = clientIt->second;
this->clients.erase(clientIt);
client->stop();
if (!this->stopping_)
{
auto clientListeners = client->getListeners();
for (const auto &listener : clientListeners)
{
this->listenToTopic(listener.topic);
}
}
}
PubSub::WebsocketContextPtr PubSub::onTLSInit(websocketpp::connection_hdl hdl)
{
WebsocketContextPtr ctx(
new boost::asio::ssl::context(boost::asio::ssl::context::tlsv12));
try
{
ctx->set_options(boost::asio::ssl::context::default_workarounds |
boost::asio::ssl::context::no_sslv2 |
boost::asio::ssl::context::single_dh_use);
}
catch (const std::exception &e)
{
qCDebug(chatterinoPubSub)
<< "Exception caught in OnTLSInit:" << e.what();
}
return ctx;
}
void PubSub::handleResponse(const PubSubMessage &message)
{
const bool failed = !message.error.isEmpty();
if (failed)
{
qCDebug(chatterinoPubSub)
<< "Error" << message.error << "on nonce" << message.nonce;
}
if (message.nonce.isEmpty())
{
// Can't do any specific handling since no nonce was specified
return;
}
if (auto oInfo = this->findNonceInfo(message.nonce); oInfo)
{
const auto info = *oInfo;
auto client = info.client.lock();
if (!client)
{
qCDebug(chatterinoPubSub) << "Client associated with nonce"
<< message.nonce << "is no longer alive";
return;
}
if (info.messageType == "LISTEN")
{
client->handleListenResponse(message);
this->handleListenResponse(info, failed);
}
else if (info.messageType == "UNLISTEN")
{
client->handleUnlistenResponse(message);
this->handleUnlistenResponse(info, failed);
}
else
{
qCDebug(chatterinoPubSub)
<< "Unhandled nonce message type" << info.messageType;
}
return;
}
qCDebug(chatterinoPubSub) << "Response on unused" << message.nonce
<< "client/topic listener mismatch?";
}
void PubSub::handleListenResponse(const NonceInfo &info, bool failed)
{
DebugCount::decrease("PubSub topic pending listens", info.topicCount);
if (failed)
{
this->diag.failedListenResponses++;
DebugCount::increase("PubSub topic failed listens", info.topicCount);
}
else
{
this->diag.listenResponses++;
DebugCount::increase("PubSub topic listening", info.topicCount);
}
}
void PubSub::handleUnlistenResponse(const NonceInfo &info, bool failed)
{
this->diag.unlistenResponses++;
DebugCount::decrease("PubSub topic pending unlistens", info.topicCount);
if (failed)
{
qCDebug(chatterinoPubSub) << "Failed unlistening to" << info.topics;
DebugCount::increase("PubSub topic failed unlistens", info.topicCount);
}
else
{
qCDebug(chatterinoPubSub) << "Successful unlistened to" << info.topics;
DebugCount::decrease("PubSub topic listening", info.topicCount);
}
}
void PubSub::handleMessageResponse(const PubSubMessageMessage &message)
{
QString topic = message.topic;
if (topic.startsWith("community-points-channel-v1."))
{
auto oInnerMessage =
message.toInner<PubSubCommunityPointsChannelV1Message>();
if (!oInnerMessage)
{
return;
}
auto innerMessage = *oInnerMessage;
switch (innerMessage.type)
{
case PubSubCommunityPointsChannelV1Message::Type::
AutomaticRewardRedeemed:
case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: {
auto redemption =
innerMessage.data.value("redemption").toObject();
this->pointReward.redeemed.invoke(redemption);
}
break;
case PubSubCommunityPointsChannelV1Message::Type::INVALID:
default: {
qCDebug(chatterinoPubSub)
<< "Invalid point event type:" << innerMessage.typeString;
}
break;
}
}
else
{
qCDebug(chatterinoPubSub) << "Unknown topic:" << topic;
return;
}
}
void PubSub::runThread()
{
qCDebug(chatterinoPubSub) << "Start pubsub manager thread";
this->websocketClient.run();
qCDebug(chatterinoPubSub) << "Done with pubsub manager thread";
}
void PubSub::listenToTopic(const QString &topic)
{
this->listen(PubSubListenMessage({topic}));
this->private_->subscribe(TopicData{.topic = std::move(topic)});
}
} // namespace chatterino
+10 -101
View File
@@ -1,29 +1,15 @@
#pragma once
#include "providers/twitch/PubSubClientOptions.hpp"
#include "providers/twitch/PubSubWebsocket.hpp"
#include "util/ExponentialBackoff.hpp"
#include "util/OnceFlag.hpp"
#include "providers/liveupdates/Diag.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ssl/context.hpp>
#include <pajlada/signals/signal.hpp>
#include <QJsonObject>
#include <QString>
#include <websocketpp/client.hpp>
#include <websocketpp/common/connection_hdl.hpp>
#include <websocketpp/common/memory.hpp>
#include <websocketpp/config/asio_client.hpp>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <thread>
#include <unordered_map>
#include <vector>
#if __has_include(<gtest/gtest_prod.h>)
# include <gtest/gtest_prod.h>
@@ -31,12 +17,7 @@
namespace chatterino {
class TwitchAccount;
class PubSubClient;
struct PubSubListenMessage;
struct PubSubMessage;
struct PubSubMessageMessage;
class PubSubManagerPrivate;
/**
* This handles the Twitch PubSub connection
@@ -47,28 +28,12 @@ struct PubSubMessageMessage;
*/
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> thread;
using Signal = pajlada::Signals::Signal<T>;
public:
PubSub(const QString &host,
std::chrono::seconds pingInterval = std::chrono::seconds(15));
std::chrono::seconds heartbeatInterval = std::chrono::seconds(15));
~PubSub();
PubSub(const PubSub &) = delete;
@@ -76,9 +41,6 @@ public:
PubSub &operator=(const PubSub &) = delete;
PubSub &operator=(PubSub &&) = delete;
/// Set up connections between itself & other parts of the application
void initialize();
struct {
Signal<const QJsonObject &> redeemed;
} pointReward;
@@ -90,12 +52,8 @@ public:
* PubSub topic: community-points-channel-v1.{channelID}
*/
void listenToChannelPointRewards(const QString &channelID);
void unlistenChannelPointRewards();
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};
@@ -103,64 +61,15 @@ public:
std::atomic<uint32_t> unlistenResponses{0};
} diag;
/// Statistics about the opened/closed connections and received messages
///
/// Used in tests.
const liveupdates::Diag &wsDiag() const;
private:
void start();
void stop();
/**
* Unlistens to all topics matching the prefix in all clients
*/
void unlistenPrefix(const QString &prefix);
void listenToTopic(const QString &topic);
void listen(PubSubListenMessage msg);
bool tryListen(PubSubListenMessage msg);
bool isListeningToTopic(const QString &topic);
void addClient();
std::vector<QString> requests;
std::atomic<bool> addingClient{false};
ExponentialBackoff<5> connectBackoff{std::chrono::milliseconds(1000)};
std::map<WebsocketHandle, std::shared_ptr<PubSubClient>,
std::owner_less<WebsocketHandle>>
clients;
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
std::optional<NonceInfo> findNonceInfo(QString nonce);
std::unordered_map<QString, NonceInfo> nonces_;
void runThread();
std::shared_ptr<boost::asio::executor_work_guard<
boost::asio::io_context::executor_type>>
work{nullptr};
const QString host_;
const PubSubClientOptions clientOptions_;
OnceFlag stoppedFlag_;
bool stopping_{false};
std::unique_ptr<PubSubManagerPrivate> private_;
#ifdef FRIEND_TEST
friend class FTest;
-32
View File
@@ -1,32 +0,0 @@
#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
+2 -2
View File
@@ -18,9 +18,9 @@ PubSubMessage::PubSubMessage(QJsonObject _object)
}
}
std::optional<PubSubMessage> parsePubSubBaseMessage(const QString &blob)
std::optional<PubSubMessage> parsePubSubBaseMessage(const QByteArray &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob));
if (jsonDoc.isNull())
{
+1 -1
View File
@@ -45,7 +45,7 @@ std::optional<InnerClass> PubSubMessage::toInner()
return InnerClass{this->nonce, data};
}
std::optional<PubSubMessage> parsePubSubBaseMessage(const QString &blob);
std::optional<PubSubMessage> parsePubSubBaseMessage(const QByteArray &blob);
} // namespace chatterino
+6 -10
View File
@@ -2,6 +2,8 @@
namespace chatterino {
using namespace Qt::Literals;
/// Sample messages coming from IRC
const QStringList &getSampleCheerMessages()
@@ -163,20 +165,14 @@ const QStringList &getSampleEmoteTestMessages()
/// Channel point reward tests
const QString &getSampleChannelRewardMessage()
QByteArray getSampleChannelRewardMessage()
{
static QString str{
R"({"type":"MESSAGE","data":{"topic":"community-points-channel-v1.11148817","message":"{\"type\":\"reward-redeemed\",\"data\":{\"timestamp\":\"2022-11-19T13:36:49.536938653Z\",\"redemption\":{\"id\":\"fd8af65d-3532-4e91-b30e-3995cefe576b\",\"user\":{\"id\":\"11148817\",\"login\":\"pajlada\",\"display_name\":\"pajlada\"},\"channel_id\":\"11148817\",\"redeemed_at\":\"2022-11-19T13:36:49.536938653Z\",\"reward\":{\"id\":\"44c3554e-58bc-4753-bf94-b615931d1072\",\"channel_id\":\"11148817\",\"title\":\"Test\",\"prompt\":\"\",\"cost\":1,\"is_user_input_required\":false,\"is_sub_only\":false,\"image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-4.png\"},\"default_image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\"},\"background_color\":\"#9147FF\",\"is_enabled\":true,\"is_paused\":false,\"is_in_stock\":true,\"max_per_stream\":{\"is_enabled\":false,\"max_per_stream\":0},\"should_redemptions_skip_request_queue\":false,\"template_id\":null,\"updated_for_indicator_at\":\"2021-03-16T00:40:29.385327044Z\",\"max_per_user_per_stream\":{\"is_enabled\":false,\"max_per_user_per_stream\":0},\"global_cooldown\":{\"is_enabled\":false,\"global_cooldown_seconds\":0},\"redemptions_redeemed_current_stream\":null,\"cooldown_expires_at\":null},\"status\":\"UNFULFILLED\",\"cursor\":\"ZmQ4YWY2NWQtMzUzMi00ZTkxLWIzMGUtMzk5NWNlZmU1NzZiX18yMDIyLTExLTE5VDEzOjM2OjQ5LjUzNjkzODY1M1o=\"}}}"}})",
};
return str;
return R"({"type":"MESSAGE","data":{"topic":"community-points-channel-v1.11148817","message":"{\"type\":\"reward-redeemed\",\"data\":{\"timestamp\":\"2022-11-19T13:36:49.536938653Z\",\"redemption\":{\"id\":\"fd8af65d-3532-4e91-b30e-3995cefe576b\",\"user\":{\"id\":\"11148817\",\"login\":\"pajlada\",\"display_name\":\"pajlada\"},\"channel_id\":\"11148817\",\"redeemed_at\":\"2022-11-19T13:36:49.536938653Z\",\"reward\":{\"id\":\"44c3554e-58bc-4753-bf94-b615931d1072\",\"channel_id\":\"11148817\",\"title\":\"Test\",\"prompt\":\"\",\"cost\":1,\"is_user_input_required\":false,\"is_sub_only\":false,\"image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/11148817/44c3554e-58bc-4753-bf94-b615931d1072/effc017c-861d-410d-abff-8bd98f1937eb/custom-4.png\"},\"default_image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\"},\"background_color\":\"#9147FF\",\"is_enabled\":true,\"is_paused\":false,\"is_in_stock\":true,\"max_per_stream\":{\"is_enabled\":false,\"max_per_stream\":0},\"should_redemptions_skip_request_queue\":false,\"template_id\":null,\"updated_for_indicator_at\":\"2021-03-16T00:40:29.385327044Z\",\"max_per_user_per_stream\":{\"is_enabled\":false,\"max_per_user_per_stream\":0},\"global_cooldown\":{\"is_enabled\":false,\"global_cooldown_seconds\":0},\"redemptions_redeemed_current_stream\":null,\"cooldown_expires_at\":null},\"status\":\"UNFULFILLED\",\"cursor\":\"ZmQ4YWY2NWQtMzUzMi00ZTkxLWIzMGUtMzk5NWNlZmU1NzZiX18yMDIyLTExLTE5VDEzOjM2OjQ5LjUzNjkzODY1M1o=\"}}}"}})"_ba;
}
const QString &getSampleChannelRewardMessage2()
QByteArray getSampleChannelRewardMessage2()
{
static QString str{
R"({"type":"MESSAGE","data":{"topic":"community-points-channel-v1.11148817","message":"{\"type\":\"reward-redeemed\",\"data\":{\"timestamp\":\"2022-11-19T13:37:55.860377616Z\",\"redemption\":{\"id\":\"e8712d29-7564-40e9-a972-d70997665605\",\"user\":{\"id\":\"11148817\",\"login\":\"pajlada\",\"display_name\":\"pajlada\"},\"channel_id\":\"11148817\",\"redeemed_at\":\"2022-11-19T13:37:55.860377616Z\",\"reward\":{\"id\":\"649ecb1f-3fa8-491e-a48d-bd50720929d9\",\"channel_id\":\"11148817\",\"title\":\"asdd\",\"prompt\":\"asd\",\"cost\":5,\"is_user_input_required\":true,\"is_sub_only\":false,\"image\":null,\"default_image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\"},\"background_color\":\"#0013A3\",\"is_enabled\":true,\"is_paused\":false,\"is_in_stock\":true,\"max_per_stream\":{\"is_enabled\":false,\"max_per_stream\":0},\"should_redemptions_skip_request_queue\":false,\"template_id\":null,\"updated_for_indicator_at\":\"2021-03-16T00:45:25.236177469Z\",\"max_per_user_per_stream\":{\"is_enabled\":false,\"max_per_user_per_stream\":0},\"global_cooldown\":{\"is_enabled\":false,\"global_cooldown_seconds\":0},\"redemptions_redeemed_current_stream\":null,\"cooldown_expires_at\":null},\"user_input\":\"TEST\",\"status\":\"UNFULFILLED\",\"cursor\":\"ZTg3MTJkMjktNzU2NC00MGU5LWE5NzItZDcwOTk3NjY1NjA1X18yMDIyLTExLTE5VDEzOjM3OjU1Ljg2MDM3NzYxNlo=\"}}}"}})",
};
return str;
return R"({"type":"MESSAGE","data":{"topic":"community-points-channel-v1.11148817","message":"{\"type\":\"reward-redeemed\",\"data\":{\"timestamp\":\"2022-11-19T13:37:55.860377616Z\",\"redemption\":{\"id\":\"e8712d29-7564-40e9-a972-d70997665605\",\"user\":{\"id\":\"11148817\",\"login\":\"pajlada\",\"display_name\":\"pajlada\"},\"channel_id\":\"11148817\",\"redeemed_at\":\"2022-11-19T13:37:55.860377616Z\",\"reward\":{\"id\":\"649ecb1f-3fa8-491e-a48d-bd50720929d9\",\"channel_id\":\"11148817\",\"title\":\"asdd\",\"prompt\":\"asd\",\"cost\":5,\"is_user_input_required\":true,\"is_sub_only\":false,\"image\":null,\"default_image\":{\"url_1x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\",\"url_2x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\",\"url_4x\":\"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\"},\"background_color\":\"#0013A3\",\"is_enabled\":true,\"is_paused\":false,\"is_in_stock\":true,\"max_per_stream\":{\"is_enabled\":false,\"max_per_stream\":0},\"should_redemptions_skip_request_queue\":false,\"template_id\":null,\"updated_for_indicator_at\":\"2021-03-16T00:45:25.236177469Z\",\"max_per_user_per_stream\":{\"is_enabled\":false,\"max_per_user_per_stream\":0},\"global_cooldown\":{\"is_enabled\":false,\"global_cooldown_seconds\":0},\"redemptions_redeemed_current_stream\":null,\"cooldown_expires_at\":null},\"user_input\":\"TEST\",\"status\":\"UNFULFILLED\",\"cursor\":\"ZTg3MTJkMjktNzU2NC00MGU5LWE5NzItZDcwOTk3NjY1NjA1X18yMDIyLTExLTE5VDEzOjM3OjU1Ljg2MDM3NzYxNlo=\"}}}"}})"_ba;
}
const QString &getSampleChannelRewardIRCMessage()
+2 -2
View File
@@ -13,8 +13,8 @@ const QStringList &getSampleEmoteTestMessages();
/// Channel point reward tests
const QString &getSampleChannelRewardMessage();
const QString &getSampleChannelRewardMessage2();
QByteArray getSampleChannelRewardMessage();
QByteArray getSampleChannelRewardMessage2();
const QString &getSampleChannelRewardIRCMessage();
/// Links
-3
View File
@@ -98,9 +98,6 @@ AboutPage::AboutPage()
addLicense(form.getElement(), "Pajlada/Serialize",
"https://github.com/pajlada/serialize",
":/licenses/pajlada_serialize.txt");
addLicense(form.getElement(), "Websocketpp",
"https://www.zaphoyd.com/websocketpp/",
":/licenses/websocketpp.txt");
#ifndef NO_QTKEYCHAIN
addLicense(form.getElement(), "QtKeychain",
"https://github.com/frankosterfeld/qtkeychain",