feat: add initial experimental Twitch Eventsub support (#5837)
Co-authored-by: nerix <nerixdev@outlook.de>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
#include "providers/twitch/eventsub/Connection.hpp"
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "common/QLogging.hpp"
|
||||
#include "controllers/accounts/AccountController.hpp"
|
||||
#include "messages/Message.hpp"
|
||||
#include "messages/MessageBuilder.hpp"
|
||||
#include "providers/twitch/eventsub/MessageBuilder.hpp"
|
||||
#include "providers/twitch/PubSubActions.hpp"
|
||||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
#include "providers/twitch/TwitchIrcServer.hpp"
|
||||
#include "util/PostToThread.hpp"
|
||||
|
||||
#include <boost/json.hpp>
|
||||
#include <qdatetime.h>
|
||||
#include <twitch-eventsub-ws/listener.hpp>
|
||||
#include <twitch-eventsub-ws/session.hpp>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace {
|
||||
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
const auto &LOG = chatterinoTwitchEventSub;
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
void Connection::onSessionWelcome(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::session_welcome::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
qCDebug(LOG) << "On session welcome:" << payload.id.c_str();
|
||||
|
||||
this->sessionID = QString::fromStdString(payload.id);
|
||||
}
|
||||
|
||||
void Connection::onNotification(lib::messages::Metadata metadata,
|
||||
const boost::json::value &jv)
|
||||
{
|
||||
(void)metadata;
|
||||
auto jsonString = boost::json::serialize(jv);
|
||||
qCDebug(LOG) << "on notification: " << jsonString.c_str();
|
||||
}
|
||||
|
||||
void Connection::onChannelBan(lib::messages::Metadata metadata,
|
||||
lib::payload::channel_ban::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
|
||||
auto roomID = QString::fromStdString(payload.event.broadcasterUserID);
|
||||
|
||||
BanAction action{};
|
||||
|
||||
action.timestamp = std::chrono::steady_clock::now();
|
||||
action.roomID = roomID;
|
||||
action.source = ActionUser{
|
||||
.id = QString::fromStdString(payload.event.moderatorUserID),
|
||||
.login = QString::fromStdString(payload.event.moderatorUserLogin),
|
||||
.displayName = QString::fromStdString(payload.event.moderatorUserName),
|
||||
};
|
||||
action.target = ActionUser{
|
||||
.id = QString::fromStdString(payload.event.userID),
|
||||
.login = QString::fromStdString(payload.event.userLogin),
|
||||
.displayName = QString::fromStdString(payload.event.userName),
|
||||
};
|
||||
action.reason = QString::fromStdString(payload.event.reason);
|
||||
if (payload.event.isPermanent)
|
||||
{
|
||||
action.duration = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
auto timeoutDuration = payload.event.timeoutDuration();
|
||||
auto timeoutDurationInSeconds =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(timeoutDuration)
|
||||
.count();
|
||||
action.duration = timeoutDurationInSeconds;
|
||||
}
|
||||
|
||||
auto chan = getApp()->getTwitch()->getChannelOrEmptyByID(roomID);
|
||||
|
||||
runInGuiThread([action{std::move(action)}, chan{std::move(chan)}] {
|
||||
auto time = QDateTime::currentDateTime();
|
||||
MessageBuilder msg(action, time);
|
||||
msg->flags.set(MessageFlag::PubSub);
|
||||
chan->addOrReplaceTimeout(msg.release(), QDateTime::currentDateTime());
|
||||
});
|
||||
}
|
||||
|
||||
void Connection::onStreamOnline(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::stream_online::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
qCDebug(LOG) << "On stream online event for channel"
|
||||
<< payload.event.broadcasterUserLogin.c_str();
|
||||
}
|
||||
|
||||
void Connection::onStreamOffline(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::stream_offline::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
qCDebug(LOG) << "On stream offline event for channel"
|
||||
<< payload.event.broadcasterUserLogin.c_str();
|
||||
}
|
||||
|
||||
void Connection::onChannelChatNotification(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_chat_notification::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
qCDebug(LOG) << "On channel chat notification for"
|
||||
<< payload.event.broadcasterUserLogin.c_str();
|
||||
}
|
||||
|
||||
void Connection::onChannelUpdate(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_update::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
qCDebug(LOG) << "On channel update for"
|
||||
<< payload.event.broadcasterUserLogin.c_str();
|
||||
}
|
||||
|
||||
void Connection::onChannelChatMessage(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_chat_message::v1::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
|
||||
qCDebug(LOG) << "Channel chat message event for"
|
||||
<< payload.event.broadcasterUserLogin.c_str();
|
||||
}
|
||||
|
||||
void Connection::onChannelModerate(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_moderate::v2::Payload payload)
|
||||
{
|
||||
(void)metadata;
|
||||
|
||||
using lib::payload::channel_moderate::v2::Action;
|
||||
|
||||
auto channelPtr = getApp()->getTwitch()->getChannelOrEmpty(
|
||||
payload.event.broadcasterUserLogin.qt());
|
||||
if (channelPtr->isEmpty())
|
||||
{
|
||||
qCDebug(LOG)
|
||||
<< "Channel moderate event for broadcaster we're not interested in"
|
||||
<< payload.event.broadcasterUserLogin.qt();
|
||||
return;
|
||||
}
|
||||
|
||||
auto *channel = dynamic_cast<TwitchChannel *>(channelPtr.get());
|
||||
if (channel == nullptr)
|
||||
{
|
||||
qCDebug(LOG)
|
||||
<< "Channel moderate event for broadcaster is not a Twitch channel?"
|
||||
<< payload.event.broadcasterUserLogin.qt();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto now = QDateTime::currentDateTime();
|
||||
|
||||
switch (payload.event.action)
|
||||
{
|
||||
case Action::Vip: {
|
||||
const auto &oAction = payload.event.vip;
|
||||
|
||||
if (!oAction.has_value())
|
||||
{
|
||||
qCWarning(LOG) << "VIP action type had no VIP action body";
|
||||
return;
|
||||
}
|
||||
auto msg =
|
||||
makeVipMessage(channel, now, payload.event, oAction.value());
|
||||
runInGuiThread([channel, msg] {
|
||||
channel->addMessage(msg, MessageContext::Original);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::Unvip: {
|
||||
const auto &oAction = payload.event.unvip;
|
||||
|
||||
if (!oAction.has_value())
|
||||
{
|
||||
qCWarning(LOG) << "UnVIP action type had no UnVIP action body";
|
||||
return;
|
||||
}
|
||||
auto msg =
|
||||
makeUnvipMessage(channel, now, payload.event, oAction.value());
|
||||
runInGuiThread([channel, msg] {
|
||||
channel->addMessage(msg, MessageContext::Original);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::Ban:
|
||||
case Action::Timeout:
|
||||
case Action::Unban:
|
||||
case Action::Untimeout:
|
||||
case Action::Clear:
|
||||
case Action::Emoteonly:
|
||||
case Action::Emoteonlyoff:
|
||||
case Action::Followers:
|
||||
case Action::Followersoff:
|
||||
case Action::Uniquechat:
|
||||
case Action::Uniquechatoff:
|
||||
case Action::Slow:
|
||||
case Action::Slowoff:
|
||||
case Action::Subscribers:
|
||||
case Action::Subscribersoff:
|
||||
case Action::Unraid:
|
||||
case Action::DeleteMessage:
|
||||
case Action::Raid:
|
||||
case Action::AddBlockedTerm:
|
||||
case Action::AddPermittedTerm:
|
||||
case Action::RemoveBlockedTerm:
|
||||
case Action::RemovePermittedTerm:
|
||||
case Action::Mod:
|
||||
case Action::Unmod:
|
||||
case Action::ApproveUnbanRequest:
|
||||
case Action::DenyUnbanRequest:
|
||||
case Action::Warn:
|
||||
case Action::SharedChatBan:
|
||||
case Action::SharedChatTimeout:
|
||||
case Action::SharedChatUnban:
|
||||
case Action::SharedChatUntimeout:
|
||||
case Action::SharedChatDelete:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
QString Connection::getSessionID() const
|
||||
{
|
||||
return this->sessionID;
|
||||
}
|
||||
|
||||
bool Connection::isSubscribedTo(const SubscriptionRequest &request) const
|
||||
{
|
||||
return this->subscriptions.contains(request);
|
||||
}
|
||||
|
||||
void Connection::markRequestSubscribed(const SubscriptionRequest &request)
|
||||
{
|
||||
this->subscriptions.emplace(request);
|
||||
}
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
|
||||
#include "twitch-eventsub-ws/listener.hpp"
|
||||
#include "twitch-eventsub-ws/session.hpp"
|
||||
|
||||
#include <boost/date_time.hpp>
|
||||
#include <QString>
|
||||
|
||||
#include <unordered_set>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
class Connection final : public lib::Listener
|
||||
{
|
||||
public:
|
||||
void onSessionWelcome(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::session_welcome::Payload payload) override;
|
||||
|
||||
void onNotification(lib::messages::Metadata metadata,
|
||||
const boost::json::value &jv) override;
|
||||
|
||||
void onChannelBan(lib::messages::Metadata metadata,
|
||||
lib::payload::channel_ban::v1::Payload payload) override;
|
||||
|
||||
void onStreamOnline(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::stream_online::v1::Payload payload) override;
|
||||
|
||||
void onStreamOffline(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::stream_offline::v1::Payload payload) override;
|
||||
|
||||
void onChannelChatNotification(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_chat_notification::v1::Payload payload) override;
|
||||
|
||||
void onChannelUpdate(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_update::v1::Payload payload) override;
|
||||
|
||||
void onChannelChatMessage(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_chat_message::v1::Payload payload) override;
|
||||
|
||||
void onChannelModerate(
|
||||
lib::messages::Metadata metadata,
|
||||
lib::payload::channel_moderate::v2::Payload payload) override;
|
||||
|
||||
QString getSessionID() const;
|
||||
|
||||
bool isSubscribedTo(const SubscriptionRequest &request) const;
|
||||
void markRequestSubscribed(const SubscriptionRequest &request);
|
||||
// TODO: Add an "markRequestUnsubscribed" method
|
||||
|
||||
private:
|
||||
QString sessionID;
|
||||
|
||||
std::unordered_set<SubscriptionRequest> subscriptions;
|
||||
};
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,318 @@
|
||||
#include "providers/twitch/eventsub/Controller.hpp"
|
||||
|
||||
#include "common/QLogging.hpp"
|
||||
#include "common/Version.hpp"
|
||||
#include "providers/twitch/api/Helix.hpp"
|
||||
#include "providers/twitch/eventsub/Connection.hpp"
|
||||
#include "util/RenameThread.hpp"
|
||||
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ssl.hpp>
|
||||
#include <boost/asio/ssl/verify_mode.hpp>
|
||||
#include <boost/certify/https_verification.hpp>
|
||||
#include <twitch-eventsub-ws/session.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
/// Enable LOCAL_EVENTSUB when you want to debug eventsub with a local instance of the Twitch CLI
|
||||
/// twitch event websocket start-server --ssl --port 3012
|
||||
constexpr bool LOCAL_EVENTSUB = false;
|
||||
|
||||
std::tuple<std::string, std::string, std::string> getEventSubHost()
|
||||
{
|
||||
if constexpr (LOCAL_EVENTSUB)
|
||||
{
|
||||
return {"localhost", "3012", "/ws"};
|
||||
}
|
||||
|
||||
return {"eventsub.wss.twitch.tv", "443", "/ws"};
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
const auto &LOG = chatterinoTwitchEventSub;
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
Controller::Controller()
|
||||
: userAgent(QStringLiteral("chatterino/%1 (%2)")
|
||||
.arg(Version::instance().version(),
|
||||
Version::instance().commitHash())
|
||||
.toUtf8()
|
||||
.toStdString())
|
||||
, ioContext(1)
|
||||
, work(boost::asio::make_work_guard(this->ioContext))
|
||||
{
|
||||
std::tie(this->eventSubHost, this->eventSubPort, this->eventSubPath) =
|
||||
getEventSubHost();
|
||||
this->thread = std::make_unique<std::thread>([this] {
|
||||
this->ioContext.run();
|
||||
});
|
||||
renameThread(*this->thread, "C2EventSub");
|
||||
|
||||
this->threadGuard = std::make_unique<ThreadGuard>(this->thread->get_id());
|
||||
}
|
||||
|
||||
Controller::~Controller()
|
||||
{
|
||||
qCInfo(LOG) << "Controller dtor start";
|
||||
this->queuedSubscriptions.clear();
|
||||
|
||||
for (const auto &weakConnection : this->connections)
|
||||
{
|
||||
auto connection = weakConnection.lock();
|
||||
if (!connection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
connection->close();
|
||||
}
|
||||
|
||||
this->work.reset();
|
||||
|
||||
if (this->thread->joinable())
|
||||
{
|
||||
this->thread->join();
|
||||
}
|
||||
else
|
||||
{
|
||||
qCWarning(LOG) << "Thread not joinable";
|
||||
}
|
||||
|
||||
qCInfo(LOG) << "Controller dtor end";
|
||||
}
|
||||
|
||||
void Controller::removeRef(const SubscriptionRequest &request)
|
||||
{
|
||||
std::lock_guard lock(this->subscriptionsMutex);
|
||||
|
||||
assert(this->activeSubscriptions.contains(request));
|
||||
|
||||
auto &xd = this->activeSubscriptions[request];
|
||||
xd.refCount--;
|
||||
qCInfo(LOG) << "Remove ref for" << request << xd.refCount;
|
||||
// todo use actual atomic things here to be smart
|
||||
if (xd.refCount <= 0)
|
||||
{
|
||||
qCInfo(LOG) << "TODO: Unsubscribe from" << request;
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionHandle Controller::subscribe(const SubscriptionRequest &request)
|
||||
{
|
||||
bool needToSubscribe = false;
|
||||
|
||||
{
|
||||
std::lock_guard lock(this->subscriptionsMutex);
|
||||
|
||||
auto &xd = this->activeSubscriptions[request];
|
||||
if (xd.refCount == 0)
|
||||
{
|
||||
needToSubscribe = true;
|
||||
}
|
||||
xd.refCount++;
|
||||
qCInfo(LOG) << "Add ref for" << request << xd.refCount;
|
||||
}
|
||||
|
||||
auto handle = std::make_unique<RawSubscriptionHandle>(request);
|
||||
|
||||
if (needToSubscribe)
|
||||
{
|
||||
boost::asio::post(this->ioContext, [this, request] {
|
||||
this->subscribe(request, false);
|
||||
});
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
void Controller::subscribe(const SubscriptionRequest &request, bool isQueued)
|
||||
{
|
||||
qCInfo(LOG) << "Subscribe request for" << request.subscriptionType;
|
||||
boost::asio::post(this->ioContext, [this, request, isQueued] {
|
||||
// 1. Flush dead connections (maybe this should not be done here)
|
||||
// TODO: implement
|
||||
|
||||
if (isQueued)
|
||||
{
|
||||
qCInfo(LOG) << "Removing subscription from queued list";
|
||||
this->queuedSubscriptions.erase(request);
|
||||
}
|
||||
|
||||
if (this->queuedSubscriptions.contains(request))
|
||||
{
|
||||
qCWarning(LOG) << "We already have a queued subscription for this, "
|
||||
"let's chill :)";
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t openButNotReadyConnections = 0;
|
||||
|
||||
// 2. Check if any currently open connection can handle this subscription
|
||||
for (const auto &weakConnection : this->connections)
|
||||
{
|
||||
auto connection = weakConnection.lock();
|
||||
if (!connection)
|
||||
{
|
||||
// TODO: remove it here?
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *listener =
|
||||
dynamic_cast<Connection *>(connection->getListener());
|
||||
|
||||
if (listener == nullptr)
|
||||
{
|
||||
// something really goofy is going on
|
||||
qCWarning(LOG) << "listener was not the correct type";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listener->getSessionID().isEmpty())
|
||||
{
|
||||
// This connection is open but it's not ready (i.e. no welcome has been posted yet)
|
||||
++openButNotReadyConnections;
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Check if this listener has room for another subscription
|
||||
|
||||
// TODO: Don't hardcode the subscription version
|
||||
getHelix()->createEventSubSubscription(
|
||||
request, listener->getSessionID(),
|
||||
[this, request, connection,
|
||||
weakConnection{weakConnection}](const auto &res) {
|
||||
qCInfo(LOG) << "success" << res;
|
||||
this->markRequestSubscribed(request, weakConnection);
|
||||
},
|
||||
[this, request](const auto &error, const auto &errorString) {
|
||||
using Error = HelixCreateEventSubSubscriptionError;
|
||||
switch (error)
|
||||
{
|
||||
case Error::BadRequest:
|
||||
qCWarning(LOG) << "BadRequest" << errorString;
|
||||
break;
|
||||
|
||||
case Error::Unauthorized:
|
||||
qCWarning(LOG) << "Unauthorized" << errorString;
|
||||
break;
|
||||
|
||||
case Error::Forbidden:
|
||||
qCWarning(LOG) << "Forbidden" << errorString;
|
||||
break;
|
||||
|
||||
case Error::Conflict:
|
||||
// This session ID is already subscribed to this request, some logic of ours is wrong
|
||||
qCWarning(LOG) << "Conflict" << errorString;
|
||||
break;
|
||||
|
||||
case Error::Ratelimited:
|
||||
qCWarning(LOG) << "Ratelimited" << errorString;
|
||||
break;
|
||||
|
||||
case Error::Forwarded:
|
||||
default:
|
||||
qCWarning(LOG)
|
||||
<< "Unhandled error, retrying subscription"
|
||||
<< errorString;
|
||||
boost::asio::post(this->ioContext, [this, request] {
|
||||
this->queueSubscription(
|
||||
request, boost::posix_time::seconds(2));
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (openButNotReadyConnections == 0)
|
||||
{
|
||||
// No connection was available to handle this subscription request, create a new connection
|
||||
this->createConnection();
|
||||
this->queueSubscription(request, boost::posix_time::millisec(500));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (openButNotReadyConnections > 1)
|
||||
{
|
||||
qCWarning(LOG) << "We have" << openButNotReadyConnections
|
||||
<< "open but not ready connections, hmmm";
|
||||
}
|
||||
|
||||
// At least one connection is open, but it has not gotten the welcome message yet
|
||||
this->queueSubscription(request, boost::posix_time::millisec(250));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void Controller::createConnection()
|
||||
{
|
||||
try
|
||||
{
|
||||
boost::asio::ssl::context sslContext{
|
||||
boost::asio::ssl::context::tlsv12_client};
|
||||
|
||||
if constexpr (!LOCAL_EVENTSUB)
|
||||
{
|
||||
sslContext.set_verify_mode(
|
||||
boost::asio::ssl::verify_peer |
|
||||
boost::asio::ssl::verify_fail_if_no_peer_cert);
|
||||
sslContext.set_default_verify_paths();
|
||||
|
||||
boost::certify::enable_native_https_server_verification(sslContext);
|
||||
}
|
||||
|
||||
auto connection = std::make_shared<lib::Session>(
|
||||
this->ioContext, sslContext, std::make_unique<Connection>());
|
||||
|
||||
this->registerConnection(connection);
|
||||
|
||||
connection->run(this->eventSubHost, this->eventSubPort,
|
||||
this->eventSubPath, this->userAgent);
|
||||
}
|
||||
catch (std::exception &e)
|
||||
{
|
||||
qCWarning(LOG) << "Error in EventSub run thread" << e.what();
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::registerConnection(std::weak_ptr<lib::Session> &&connection)
|
||||
{
|
||||
this->threadGuard->guard();
|
||||
|
||||
this->connections.emplace_back(std::move(connection));
|
||||
}
|
||||
|
||||
void Controller::queueSubscription(const SubscriptionRequest &request,
|
||||
boost::posix_time::time_duration delay)
|
||||
{
|
||||
this->threadGuard->guard();
|
||||
|
||||
auto resubTimer =
|
||||
std::make_unique<boost::asio::deadline_timer>(this->ioContext);
|
||||
resubTimer->expires_from_now(delay);
|
||||
resubTimer->async_wait([this, request](const auto &ec) {
|
||||
if (!ec)
|
||||
{
|
||||
// The timer passed naturally
|
||||
this->subscribe(request, true);
|
||||
}
|
||||
});
|
||||
|
||||
this->queuedSubscriptions.emplace(request, std::move(resubTimer));
|
||||
}
|
||||
|
||||
void Controller::markRequestSubscribed(const SubscriptionRequest &request,
|
||||
std::weak_ptr<lib::Session> connection)
|
||||
{
|
||||
std::lock_guard lock(this->subscriptionsMutex);
|
||||
|
||||
this->activeSubscriptions[request].connection = std::move(connection);
|
||||
}
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include "providers/twitch/eventsub/SubscriptionHandle.hpp"
|
||||
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
|
||||
#include "twitch-eventsub-ws/session.hpp"
|
||||
#include "util/ThreadGuard.hpp"
|
||||
|
||||
#include <boost/asio/executor_work_guard.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/functional/hash.hpp>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
class IController
|
||||
{
|
||||
public:
|
||||
virtual ~IController() = default;
|
||||
|
||||
virtual void removeRef(const SubscriptionRequest &request) = 0;
|
||||
|
||||
/// Subscribe will make a request to each open connection and ask them to
|
||||
/// add this subscription.
|
||||
///
|
||||
/// If this subscription already exists, this call is a no-op.
|
||||
///
|
||||
/// If no open connection has room for this subscription, this function will
|
||||
/// create a new connection and queue up the subscription to run again after X seconds.
|
||||
///
|
||||
/// TODO: Return a SubscriptionHandle that handles unsubscriptions
|
||||
/// Dupe subscriptions should return shared subscription handles
|
||||
/// So no more owners of the subscription handle means we send an unsubscribe request
|
||||
[[nodiscard]] virtual SubscriptionHandle subscribe(
|
||||
const SubscriptionRequest &request) = 0;
|
||||
};
|
||||
|
||||
class Controller : public IController
|
||||
{
|
||||
public:
|
||||
Controller();
|
||||
~Controller() override;
|
||||
|
||||
void removeRef(const SubscriptionRequest &request) override;
|
||||
|
||||
[[nodiscard]] SubscriptionHandle subscribe(
|
||||
const SubscriptionRequest &request) override;
|
||||
|
||||
private:
|
||||
void subscribe(const SubscriptionRequest &request, bool isQueued);
|
||||
|
||||
void createConnection();
|
||||
void registerConnection(std::weak_ptr<lib::Session> &&connection);
|
||||
|
||||
void queueSubscription(const SubscriptionRequest &request,
|
||||
boost::posix_time::time_duration delay);
|
||||
|
||||
void markRequestSubscribed(const SubscriptionRequest &request,
|
||||
std::weak_ptr<lib::Session> connection);
|
||||
|
||||
const std::string userAgent;
|
||||
|
||||
std::string eventSubHost;
|
||||
std::string eventSubPort;
|
||||
std::string eventSubPath;
|
||||
|
||||
std::unique_ptr<std::thread> thread;
|
||||
std::unique_ptr<ThreadGuard> threadGuard;
|
||||
boost::asio::io_context ioContext;
|
||||
boost::asio::executor_work_guard<boost::asio::io_context::executor_type>
|
||||
work;
|
||||
|
||||
std::vector<std::weak_ptr<lib::Session>> connections;
|
||||
|
||||
struct XD {
|
||||
int32_t refCount = 0;
|
||||
std::weak_ptr<lib::Session> connection;
|
||||
};
|
||||
|
||||
std::mutex subscriptionsMutex;
|
||||
std::unordered_map<SubscriptionRequest, XD> activeSubscriptions;
|
||||
|
||||
std::unordered_map<SubscriptionRequest,
|
||||
std::unique_ptr<boost::asio::deadline_timer>>
|
||||
queuedSubscriptions;
|
||||
};
|
||||
|
||||
class DummyController : public IController
|
||||
{
|
||||
public:
|
||||
~DummyController() override = default;
|
||||
|
||||
void removeRef(const SubscriptionRequest &request) override
|
||||
{
|
||||
(void)request;
|
||||
};
|
||||
|
||||
[[nodiscard]] SubscriptionHandle subscribe(
|
||||
const SubscriptionRequest &request) override
|
||||
{
|
||||
(void)request;
|
||||
return {};
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "providers/twitch/eventsub/MessageBuilder.hpp"
|
||||
|
||||
#include "messages/MessageBuilder.hpp"
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
MessagePtr makeVipMessage(
|
||||
TwitchChannel *channel, const QDateTime &time,
|
||||
const lib::payload::channel_moderate::v2::Event &event,
|
||||
const lib::payload::channel_moderate::v2::Vip &action)
|
||||
{
|
||||
MessageBuilder builder;
|
||||
|
||||
QString text;
|
||||
|
||||
builder.emplace<TimestampElement>();
|
||||
builder->flags.set(MessageFlag::System);
|
||||
builder->flags.set(MessageFlag::Timeout);
|
||||
builder->loginName = event.moderatorUserLogin.qt();
|
||||
|
||||
builder.emplace<MentionElement>(
|
||||
event.moderatorUserName.qt(), event.moderatorUserLogin.qt(),
|
||||
MessageColor::System,
|
||||
channel->getUserColor(event.moderatorUserLogin.qt()));
|
||||
text.append(event.moderatorUserLogin.qt() + " ");
|
||||
|
||||
builder.emplaceSystemTextAndUpdate("has added", text);
|
||||
|
||||
builder.emplace<MentionElement>(
|
||||
action.userName.qt(), action.userLogin.qt(), MessageColor::System,
|
||||
channel->getUserColor(action.userLogin.qt()));
|
||||
text.append(action.userLogin.qt() + " ");
|
||||
|
||||
builder.emplaceSystemTextAndUpdate("as a VIP of this channel.", text);
|
||||
|
||||
builder.message().messageText = text;
|
||||
builder.message().searchText = text;
|
||||
|
||||
builder.message().serverReceivedTime = time;
|
||||
|
||||
return builder.release();
|
||||
}
|
||||
|
||||
MessagePtr makeUnvipMessage(
|
||||
TwitchChannel *channel, const QDateTime &time,
|
||||
const lib::payload::channel_moderate::v2::Event &event,
|
||||
const lib::payload::channel_moderate::v2::Unvip &action)
|
||||
{
|
||||
MessageBuilder builder;
|
||||
|
||||
QString text;
|
||||
|
||||
builder.emplace<TimestampElement>();
|
||||
builder->flags.set(MessageFlag::System);
|
||||
builder->flags.set(MessageFlag::Timeout);
|
||||
builder->loginName = event.moderatorUserLogin.qt();
|
||||
|
||||
builder.emplace<MentionElement>(
|
||||
event.moderatorUserName.qt(), event.moderatorUserLogin.qt(),
|
||||
MessageColor::System,
|
||||
channel->getUserColor(event.moderatorUserLogin.qt()));
|
||||
text.append(event.moderatorUserLogin.qt() + " ");
|
||||
|
||||
builder.emplaceSystemTextAndUpdate("has removed", text);
|
||||
|
||||
builder.emplace<MentionElement>(
|
||||
action.userName.qt(), action.userLogin.qt(), MessageColor::System,
|
||||
channel->getUserColor(action.userLogin.qt()));
|
||||
text.append(action.userLogin.qt() + " ");
|
||||
|
||||
builder.emplaceSystemTextAndUpdate("as a VIP of this channel.", text);
|
||||
|
||||
builder.message().messageText = text;
|
||||
builder.message().searchText = text;
|
||||
|
||||
builder.message().serverReceivedTime = time;
|
||||
|
||||
return builder.release();
|
||||
}
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "messages/Message.hpp"
|
||||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
#include "twitch-eventsub-ws/payloads/channel-moderate-v2.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
/// <BROADCASTER> has added <USER> as a VIP of this channel.
|
||||
MessagePtr makeVipMessage(
|
||||
TwitchChannel *channel, const QDateTime &time,
|
||||
const lib::payload::channel_moderate::v2::Event &event,
|
||||
const lib::payload::channel_moderate::v2::Vip &action);
|
||||
|
||||
/// <BROADCASTER> has removed <USER> as a VIP of this channel.
|
||||
MessagePtr makeUnvipMessage(
|
||||
TwitchChannel *channel, const QDateTime &time,
|
||||
const lib::payload::channel_moderate::v2::Event &event,
|
||||
const lib::payload::channel_moderate::v2::Unvip &action);
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "providers/twitch/eventsub/SubscriptionHandle.hpp"
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "providers/twitch/eventsub/Controller.hpp"
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
RawSubscriptionHandle::RawSubscriptionHandle(SubscriptionRequest request_)
|
||||
: request(std::move(request_))
|
||||
{
|
||||
// getApp()->getEventSub()->addRef(request);
|
||||
}
|
||||
|
||||
RawSubscriptionHandle::~RawSubscriptionHandle()
|
||||
{
|
||||
auto *app = tryGetApp();
|
||||
if (app == nullptr)
|
||||
{
|
||||
// We're shutting down, assume the unsubscription has been taken care of
|
||||
return;
|
||||
}
|
||||
app->getEventSub()->removeRef(request);
|
||||
}
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
struct RawSubscriptionHandle {
|
||||
const SubscriptionRequest request;
|
||||
|
||||
RawSubscriptionHandle(SubscriptionRequest request_);
|
||||
|
||||
~RawSubscriptionHandle();
|
||||
};
|
||||
|
||||
using SubscriptionHandle = std::unique_ptr<RawSubscriptionHandle>;
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
QDebug &operator<<(QDebug &dbg, const SubscriptionRequest &v)
|
||||
{
|
||||
dbg << "eventsub::SubscriptionRequest{ type:" << v.subscriptionType
|
||||
<< "version:" << v.subscriptionVersion;
|
||||
if (!v.conditions.empty())
|
||||
{
|
||||
dbg << "conditions:[";
|
||||
for (const auto &[conditionKey, conditionValue] : v.conditions)
|
||||
{
|
||||
dbg << conditionKey << "=" << conditionValue << ',';
|
||||
}
|
||||
dbg << ']';
|
||||
}
|
||||
dbg << '}';
|
||||
return dbg;
|
||||
}
|
||||
|
||||
bool operator==(const SubscriptionRequest &lhs, const SubscriptionRequest &rhs)
|
||||
{
|
||||
return std::tie(lhs.subscriptionType, lhs.subscriptionVersion,
|
||||
lhs.conditions) == std::tie(rhs.subscriptionType,
|
||||
rhs.subscriptionVersion,
|
||||
rhs.conditions);
|
||||
}
|
||||
|
||||
bool operator!=(const SubscriptionRequest &lhs, const SubscriptionRequest &rhs)
|
||||
{
|
||||
return !(lhs == rhs);
|
||||
}
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <boost/functional/hash.hpp>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace chatterino::eventsub {
|
||||
|
||||
struct SubscriptionRequest {
|
||||
/// e.g. "channel.ban"
|
||||
/// can be made into an enum later
|
||||
QString subscriptionType;
|
||||
|
||||
// e.g. "1"
|
||||
// maybe this should be part of the enum later
|
||||
QString subscriptionVersion;
|
||||
|
||||
/// Optional list of conditions for the subscription
|
||||
std::vector<std::pair<QString, QString>> conditions;
|
||||
|
||||
friend QDebug &operator<<(QDebug &dbg, const SubscriptionRequest &v);
|
||||
};
|
||||
|
||||
bool operator==(const SubscriptionRequest &lhs, const SubscriptionRequest &rhs);
|
||||
bool operator!=(const SubscriptionRequest &lhs, const SubscriptionRequest &rhs);
|
||||
|
||||
} // namespace chatterino::eventsub
|
||||
|
||||
namespace std {
|
||||
|
||||
template <>
|
||||
struct hash<chatterino::eventsub::SubscriptionRequest> {
|
||||
size_t operator()(const chatterino::eventsub::SubscriptionRequest &v) const
|
||||
{
|
||||
size_t seed = 0;
|
||||
|
||||
boost::hash_combine(seed, qHash(v.subscriptionType));
|
||||
boost::hash_combine(seed, qHash(v.subscriptionVersion));
|
||||
|
||||
for (const auto &[conditionKey, conditionValue] : v.conditions)
|
||||
{
|
||||
boost::hash_combine(seed, qHash(conditionKey));
|
||||
boost::hash_combine(seed, qHash(conditionValue));
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace std
|
||||
Reference in New Issue
Block a user