feat: add initial experimental Twitch Eventsub support (#5837)
Co-authored-by: nerix <nerixdev@outlook.de>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -638,6 +638,10 @@ public:
|
||||
"/sound/backend",
|
||||
SoundBackend::Miniaudio,
|
||||
};
|
||||
BoolSetting enableExperimentalEventSub = {
|
||||
"/eventsub/enableExperimental",
|
||||
false,
|
||||
};
|
||||
|
||||
private:
|
||||
ChatterinoSetting<std::vector<HighlightPhrase>> highlightedMessagesSetting =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user