feat: add initial experimental Twitch Eventsub support (#5837)

Co-authored-by: nerix <nerixdev@outlook.de>
This commit is contained in:
pajlada
2025-02-02 17:03:24 +01:00
committed by GitHub
parent f9f7d47b43
commit 0f8a29fdb9
130 changed files with 19656 additions and 8 deletions
+22
View File
@@ -18,6 +18,7 @@
#include "providers/pronouns/Pronouns.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/eventsub/Controller.hpp"
#include "providers/twitch/TwitchBadges.hpp"
#include "singletons/ImageUploader.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
@@ -120,6 +121,18 @@ SeventvEventAPI *makeSeventvEventAPI(Settings &settings)
return nullptr;
}
eventsub::IController *makeEventSubController(Settings &settings)
{
bool enabled = settings.enableExperimentalEventSub;
if (enabled)
{
return new eventsub::Controller();
}
return new eventsub::DummyController();
}
const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv";
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -180,6 +193,7 @@ Application::Application(Settings &_settings, const Paths &paths,
, streamerMode(new StreamerMode)
, twitchUsers(new TwitchUsers)
, pronouns(new pronouns::Pronouns)
, eventSub(makeEventSubController(_settings))
#ifdef CHATTERINO_HAVE_PLUGINS
, plugins(new PluginController(paths))
#endif
@@ -576,6 +590,14 @@ pronouns::Pronouns *Application::getPronouns()
return this->pronouns.get();
}
eventsub::IController *Application::getEventSub()
{
assertInGuiThread();
assert(this->eventSub);
return this->eventSub.get();
}
void Application::save()
{
this->commands->save();
+6
View File
@@ -57,6 +57,9 @@ class ITwitchUsers;
namespace pronouns {
class Pronouns;
} // namespace pronouns
namespace eventsub {
class IController;
} // namespace eventsub
class IApplication
{
@@ -109,6 +112,7 @@ public:
virtual IStreamerMode *getStreamerMode() = 0;
virtual ITwitchUsers *getTwitchUsers() = 0;
virtual pronouns::Pronouns *getPronouns() = 0;
virtual eventsub::IController *getEventSub() = 0;
};
class Application : public IApplication
@@ -174,6 +178,7 @@ private:
std::unique_ptr<IStreamerMode> streamerMode;
std::unique_ptr<ITwitchUsers> twitchUsers;
std::unique_ptr<pronouns::Pronouns> pronouns;
std::unique_ptr<eventsub::IController> eventSub;
#ifdef CHATTERINO_HAVE_PLUGINS
std::unique_ptr<PluginController> plugins;
#endif
@@ -221,6 +226,7 @@ public:
SeventvEmotes *getSeventvEmotes() override;
SeventvEventAPI *getSeventvEventAPI() override;
pronouns::Pronouns *getPronouns() override;
eventsub::IController *getEventSub() override;
ILinkResolver *getLinkResolver() override;
IStreamerMode *getStreamerMode() override;
+13
View File
@@ -428,6 +428,17 @@ set(SOURCE_FILES
providers/twitch/TwitchUsers.cpp
providers/twitch/TwitchUsers.hpp
providers/twitch/eventsub/Connection.cpp
providers/twitch/eventsub/Connection.hpp
providers/twitch/eventsub/Controller.cpp
providers/twitch/eventsub/Controller.hpp
providers/twitch/eventsub/MessageBuilder.cpp
providers/twitch/eventsub/MessageBuilder.hpp
providers/twitch/eventsub/SubscriptionHandle.cpp
providers/twitch/eventsub/SubscriptionHandle.hpp
providers/twitch/eventsub/SubscriptionRequest.cpp
providers/twitch/eventsub/SubscriptionRequest.hpp
providers/twitch/pubsubmessages/AutoMod.cpp
providers/twitch/pubsubmessages/AutoMod.hpp
providers/twitch/pubsubmessages/Base.cpp
@@ -804,6 +815,8 @@ target_link_libraries(${LIBRARY_PROJECT}
LRUCache
MagicEnum
$<$<BOOL:${WIN32}>:Wtsapi32>
twitch-eventsub-ws
BoostCertify
)
if (CHATTERINO_PLUGINS)
target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua sol2::sol2)
+2
View File
@@ -52,6 +52,8 @@ Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold);
Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold);
Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold);
Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold);
Q_LOGGING_CATEGORY(chatterinoTwitchEventSub, "chatterino.twitch.eventsub",
logThreshold);
Q_LOGGING_CATEGORY(chatterinoTwitchLiveController,
"chatterino.twitch.livecontroller", logThreshold);
Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold);
+1
View File
@@ -40,6 +40,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchEventSub);
Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchLiveController);
Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate);
Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket);
@@ -461,6 +461,8 @@ CommandController::CommandController(const Paths &paths)
this->registerCommand("/debug-test", &commands::debugTest);
this->registerCommand("/debug-eventsub", &commands::debugEventSub);
this->registerCommand("/shield", &commands::shieldModeOn);
this->registerCommand("/shieldoff", &commands::shieldModeOff);
@@ -9,6 +9,7 @@
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp"
#include "messages/MessageElement.hpp"
#include "providers/twitch/eventsub/Controller.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
@@ -189,4 +190,57 @@ QString debugTest(const CommandContext &ctx)
return "";
}
QString debugEventSub(const CommandContext &ctx)
{
(void)ctx;
if (ctx.twitchChannel == nullptr)
{
return "";
}
// purposefully subscribe multiple times to ensure we can't sub too many times
// or create too many connections
static eventsub::SubscriptionHandle handle1;
handle1 = getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "channel.ban",
.subscriptionVersion = "1",
.conditions =
{
{
"broadcaster_user_id",
ctx.twitchChannel->roomId(),
},
},
});
auto handle2 =
getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "channel.ban",
.subscriptionVersion = "1",
.conditions =
{
{
"broadcaster_user_id",
ctx.twitchChannel->roomId(),
},
},
});
auto handle3 =
getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "channel.ban",
.subscriptionVersion = "1",
.conditions =
{
{
"broadcaster_user_id",
ctx.twitchChannel->roomId(),
},
},
});
return "";
}
} // namespace chatterino::commands
@@ -24,4 +24,6 @@ QString forceImageUnload(const CommandContext &ctx);
QString debugTest(const CommandContext &ctx);
QString debugEventSub(const CommandContext &ctx);
} // namespace chatterino::commands
+2
View File
@@ -44,6 +44,8 @@ struct Message {
QString id;
QString searchText;
QString messageText;
// TODO: This field is used ambiguously, it could use a comment or a name change to
// clarify the intent of the field
QString loginName;
QString displayName;
QString localizedName;
+6 -6
View File
@@ -173,6 +173,12 @@ public:
void appendOrEmplaceSystemTextAndUpdate(const QString &text,
QString &toUpdate);
// Helper method that emplaces some text stylized as system text
// and then appends that text to the QString parameter "toUpdate".
// Returns the TextElement that was emplaced.
TextElement *emplaceSystemTextAndUpdate(const QString &text,
QString &toUpdate);
static void triggerHighlights(const Channel *channel,
const HighlightAlert &alert);
@@ -287,12 +293,6 @@ private:
MessageElement &back();
std::unique_ptr<MessageElement> releaseBack();
// Helper method that emplaces some text stylized as system text
// and then appends that text to the QString parameter "toUpdate".
// Returns the TextElement that was emplaced.
TextElement *emplaceSystemTextAndUpdate(const QString &text,
QString &toUpdate);
void parse();
void parseUsernameColor(const QVariantMap &tags, const QString &userID);
void parseUsername(const Communi::IrcMessage *ircMessage,
+16
View File
@@ -918,6 +918,22 @@ MentionElement::MentionElement(const QString &displayName, QString loginName_,
{
}
template <typename>
MentionElement::MentionElement(const QString &displayName, QString loginName_,
MessageColor fallbackColor_, QColor userColor_)
: TextElement(displayName,
{MessageElementFlag::Text, MessageElementFlag::Mention})
, fallbackColor(fallbackColor_)
, userColor(userColor_.isValid() ? userColor_ : fallbackColor_)
, userLoginName(std::move(loginName_))
{
}
template MentionElement::MentionElement(const QString &displayName,
QString loginName_,
MessageColor fallbackColor_,
QColor userColor_);
void MentionElement::addToContainer(MessageLayoutContainer &container,
const MessageLayoutContext &ctx)
{
+9 -2
View File
@@ -340,8 +340,15 @@ private:
class MentionElement : public TextElement
{
public:
MentionElement(const QString &displayName, QString loginName_,
MessageColor fallbackColor_, MessageColor userColor_);
explicit MentionElement(const QString &displayName, QString loginName_,
MessageColor fallbackColor_,
MessageColor userColor_);
/// Deprioritized ctor allowing us to pass through a potentially invalid userColor_
///
/// If the userColor_ is invalid, we fall back to the fallbackColor_
template <typename = void>
explicit MentionElement(const QString &displayName, QString loginName_,
MessageColor fallbackColor_, QColor userColor_);
~MentionElement() override = default;
MentionElement(const MentionElement &) = delete;
MentionElement(MentionElement &&) = delete;
+34
View File
@@ -29,6 +29,7 @@
#include "providers/seventv/SeventvEventAPI.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/eventsub/Controller.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/TwitchAccount.hpp"
@@ -1472,6 +1473,39 @@ void TwitchChannel::refreshPubSub()
{
getApp()->getTwitchPubSub()->listenToAutomod(roomId);
getApp()->getTwitchPubSub()->listenToLowTrustUsers(roomId);
this->eventSubChannelBanHandle =
getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "channel.ban",
.subscriptionVersion = "1",
.conditions =
{
{
"broadcaster_user_id",
roomId,
},
},
});
this->eventSubChannelModerateHandle =
getApp()->getEventSub()->subscribe(eventsub::SubscriptionRequest{
.subscriptionType = "channel.moderate",
.subscriptionVersion = "2",
.conditions =
{
{
"broadcaster_user_id",
roomId,
},
{
"moderator_user_id",
currentAccount->getUserId(),
},
},
});
}
else
{
this->eventSubChannelBanHandle.reset();
}
getApp()->getTwitchPubSub()->listenToChannelPointRewards(roomId);
}
+4
View File
@@ -8,6 +8,7 @@
#include "common/UniqueAccess.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/twitch/eventsub/SubscriptionHandle.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "util/QStringHash.hpp"
#include "util/ThreadGuard.hpp"
@@ -499,6 +500,9 @@ private:
pajlada::Signals::SignalHolder signalHolder_;
std::vector<boost::signals2::scoped_connection> bSignals_;
eventsub::SubscriptionHandle eventSubChannelBanHandle;
eventsub::SubscriptionHandle eventSubChannelModerateHandle;
friend class TwitchIrcServer;
friend class MessageBuilder;
friend class IrcMessageHandler;
+118
View File
@@ -3216,6 +3216,124 @@ void Helix::getFollowedChannel(
.execute();
}
void Helix::createEventSubSubscription(
const eventsub::SubscriptionRequest &request, const QString &sessionID,
ResultCallback<HelixCreateEventSubSubscriptionResponse> successCallback,
FailureCallback<HelixCreateEventSubSubscriptionError, QString>
failureCallback)
{
using Error = HelixCreateEventSubSubscriptionError;
QJsonObject condition;
for (const auto &[conditionKey, conditionValue] : request.conditions)
{
condition.insert(conditionKey, conditionValue);
}
QJsonObject body;
body.insert("type", request.subscriptionType);
body.insert("version", request.subscriptionVersion);
body.insert("condition", condition);
QJsonObject transport;
transport.insert("method", "websocket");
transport.insert("session_id", sessionID);
body.insert("transport", transport);
this->makePost("eventsub/subscriptions", {})
.json(body)
.onSuccess([successCallback](const auto &result) {
if (result.status() != 202)
{
qCWarning(chatterinoTwitchEventSub)
<< "Success result for creating eventsub subscription was "
<< result.formatError() << "but we expected it to be 202";
}
HelixCreateEventSubSubscriptionResponse response(
result.parseJson());
successCallback(response);
})
.onError([failureCallback](const NetworkResult &result) {
if (!result.status())
{
failureCallback(Error::Forwarded, result.formatError());
return;
}
const auto obj = result.parseJson();
auto message = obj["message"].toString();
switch (*result.status())
{
case 400: {
failureCallback(Error::BadRequest, message);
}
break;
case 401: {
failureCallback(Error::Unauthorized, message);
}
break;
case 403: {
failureCallback(Error::Forbidden, message);
}
break;
case 409: {
failureCallback(Error::Conflict, message);
}
break;
case 429: {
failureCallback(Error::Ratelimited, message);
}
break;
case 500: {
if (message.isEmpty())
{
failureCallback(Error::Forwarded,
"Twitch internal server error");
}
else
{
failureCallback(Error::Forwarded, message);
}
}
break;
default: {
qCWarning(chatterinoTwitchEventSub)
<< "Helix Create EventSub Subscription, unhandled "
"error data:"
<< result.formatError() << result.getData() << obj;
failureCallback(Error::Forwarded, message);
}
}
})
.execute();
}
QDebug &operator<<(QDebug &dbg,
const HelixCreateEventSubSubscriptionResponse &data)
{
dbg << "HelixCreateEventSubSubscriptionResponse{ id:" << data.subscriptionID
<< "status:" << data.subscriptionStatus
<< "type:" << data.subscriptionType
<< "version:" << data.subscriptionVersion
<< "condition:" << data.subscriptionCondition
<< "createdAt:" << data.subscriptionCreatedAt
<< "sessionID:" << data.subscriptionSessionID
<< "connectedAt:" << data.subscriptionConnectedAt
<< "cost:" << data.subscriptionCost << "total:" << data.total
<< "totalCost:" << data.totalCost
<< "maxTotalCost:" << data.maxTotalCost << '}';
return dbg;
}
NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery,
NetworkRequestType type)
{
+74
View File
@@ -2,6 +2,7 @@
#include "common/Aliases.hpp"
#include "common/network/NetworkRequest.hpp"
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "util/Helpers.hpp"
#include "util/QStringHash.hpp"
@@ -809,6 +810,65 @@ struct HelixPaginationState {
bool done;
};
struct HelixCreateEventSubSubscriptionResponse {
QString subscriptionID;
QString subscriptionStatus;
QString subscriptionType;
QString subscriptionVersion;
QJsonObject subscriptionCondition;
QString subscriptionCreatedAt;
QString subscriptionSessionID;
QString subscriptionConnectedAt;
int subscriptionCost;
int total;
int totalCost;
int maxTotalCost;
explicit HelixCreateEventSubSubscriptionResponse(
const QJsonObject &jsonObject)
{
{
auto jsonData = jsonObject.value("data").toArray().at(0).toObject();
this->subscriptionID = jsonData.value("id").toString();
this->subscriptionStatus = jsonData.value("status").toString();
this->subscriptionType = jsonData.value("type").toString();
this->subscriptionVersion = jsonData.value("version").toString();
this->subscriptionCondition =
jsonData.value("condition").toObject();
this->subscriptionCreatedAt =
jsonData.value("created_at").toString();
this->subscriptionSessionID = jsonData.value("transport")
.toObject()
.value("session_id")
.toString();
this->subscriptionConnectedAt = jsonData.value("transport")
.toObject()
.value("connected_at")
.toString();
this->subscriptionCost = jsonData.value("cost").toInt();
}
this->total = jsonObject.value("total").toInt();
this->totalCost = jsonObject.value("total_cost").toInt();
this->maxTotalCost = jsonObject.value("max_total_cost").toInt();
}
friend QDebug &operator<<(
QDebug &dbg, const HelixCreateEventSubSubscriptionResponse &data);
};
enum class HelixCreateEventSubSubscriptionError : std::uint8_t {
BadRequest,
Unauthorized,
Forbidden,
Conflict,
Ratelimited,
// The error message is forwarded directly from the Twitch API
Forwarded,
};
class IHelix
{
public:
@@ -1150,6 +1210,13 @@ public:
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) = 0;
// https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription
virtual void createEventSubSubscription(
const eventsub::SubscriptionRequest &request, const QString &sessionID,
ResultCallback<HelixCreateEventSubSubscriptionResponse> successCallback,
FailureCallback<HelixCreateEventSubSubscriptionError, QString>
failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0;
protected:
@@ -1493,6 +1560,13 @@ public:
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) final;
// https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription
void createEventSubSubscription(
const eventsub::SubscriptionRequest &request, const QString &sessionID,
ResultCallback<HelixCreateEventSubSubscriptionResponse> successCallback,
FailureCallback<HelixCreateEventSubSubscriptionError, QString>
failureCallback) final;
void update(QString clientId, QString oauthToken) final;
static void initialize();
@@ -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
+4
View File
@@ -638,6 +638,10 @@ public:
"/sound/backend",
SoundBackend::Miniaudio,
};
BoolSetting enableExperimentalEventSub = {
"/eventsub/enableExperimental",
false,
};
private:
ChatterinoSetting<std::vector<HighlightPhrase>> highlightedMessagesSetting =
+9
View File
@@ -14,6 +14,15 @@ struct ThreadGuard {
mutable std::optional<std::thread::id> threadID;
#endif
ThreadGuard() = default;
explicit ThreadGuard(std::thread::id threadID_)
#ifndef NDEBUG
: threadID(threadID_)
#endif
{
}
inline void guard() const
{
#ifndef NDEBUG
+3
View File
@@ -129,6 +129,9 @@ AboutPage::AboutPage()
addLicense(form.getElement(), "expected-lite",
"https://github.com/martinmoene/expected-lite",
":/licenses/expected-lite.txt");
addLicense(form.getElement(), "Howard Hinnant's date.h",
"https://github.com/HowardHinnant/date",
":/licenses/howard-hinnant-date.txt");
}
// Attributions
@@ -1273,6 +1273,10 @@ void GeneralPage::initLayout(GeneralPageView &layout)
"with sound playback on your system")
->addTo(layout);
layout.addCheckbox(
"Enable experimental Twitch EventSub support (requires restart)",
s.enableExperimentalEventSub);
layout.addStretch();
// invisible element for width