mirror of
https://github.com/WarmUpTill/SceneSwitcher.git
synced 2026-03-21 17:34:57 -05:00
Add Twitch chat support
* Enable sending chat messages via the Twitch action * Enable checking for chat messages using the Twitch condition
This commit is contained in:
parent
50dc404689
commit
3805aaeeec
|
|
@ -541,6 +541,7 @@ AdvSceneSwitcher.condition.twitch.type.event.channel.stream.online.rerun="Stream
|
|||
AdvSceneSwitcher.condition.twitch.type.polling.channel.live="Stream is currently live"
|
||||
AdvSceneSwitcher.condition.twitch.type.polling.channel.title="Current title matches"
|
||||
AdvSceneSwitcher.condition.twitch.type.polling.channel.category="Current category is"
|
||||
AdvSceneSwitcher.condition.twitch.type.chat.message="Chat message received"
|
||||
AdvSceneSwitcher.condition.twitch.categorySelectionDisabled="Cannot select category without selecting a Twitch account first!"
|
||||
AdvSceneSwitcher.condition.twitch.entry="Channel{{channel}}{{conditions}}{{pointsReward}}{{streamTitle}}{{regex}}{{category}}"
|
||||
AdvSceneSwitcher.condition.twitch.entry.account="Check using account{{account}}"
|
||||
|
|
@ -860,8 +861,10 @@ AdvSceneSwitcher.action.twitch.type.announcement="Send chat announcement"
|
|||
AdvSceneSwitcher.action.twitch.type.emoteOnlyEnable="Enable chat's emote-only mode"
|
||||
AdvSceneSwitcher.action.twitch.type.emoteOnlyDisable="Disable chat's emote-only mode"
|
||||
AdvSceneSwitcher.action.twitch.type.raid="Raid channel"
|
||||
AdvSceneSwitcher.action.twitch.type.sendChatMessage="Send chat message"
|
||||
AdvSceneSwitcher.action.twitch.categorySelectionDisabled="Cannot select category without selecting a Twitch account first!"
|
||||
AdvSceneSwitcher.action.twitch.entry.line1="On{{account}}{{actions}}{{streamTitle}}{{category}}{{markerDescription}}{{clipHasDelay}}{{duration}}{{announcementColor}}{{channel}}"
|
||||
AdvSceneSwitcher.action.twitch.entry.default="On{{account}}{{actions}}{{streamTitle}}{{category}}{{markerDescription}}{{clipHasDelay}}{{duration}}{{announcementColor}}{{channel}}"
|
||||
AdvSceneSwitcher.action.twitch.entry.chat="Using account{{account}}{{actions}}on{{channel}}{{streamTitle}}{{category}}{{markerDescription}}{{clipHasDelay}}{{duration}}{{announcementColor}}"
|
||||
AdvSceneSwitcher.action.twitch.title.title="Enter title"
|
||||
AdvSceneSwitcher.action.twitch.marker.description="Describe marker"
|
||||
AdvSceneSwitcher.action.twitch.clip.hasDelay="Add a slight delay before capturing the clip"
|
||||
|
|
@ -1047,6 +1050,8 @@ AdvSceneSwitcher.twitchToken.channel.redemptions.read="Read channel's point rewa
|
|||
AdvSceneSwitcher.twitchToken.channel.redemptions.manage="Manage channel's point rewards and their redemptions."
|
||||
AdvSceneSwitcher.twitchToken.channel.moderate="Moderate the channel."
|
||||
AdvSceneSwitcher.twitchToken.channel.moderation.read="Read channel's moderation data."
|
||||
AdvSceneSwitcher.twitchToken.chat.read="View live stream chat messages."
|
||||
AdvSceneSwitcher.twitchToken.chat.edit="Send live stream chat messages."
|
||||
|
||||
AdvSceneSwitcher.twitch.selection.channel.open="Open channel"
|
||||
AdvSceneSwitcher.twitch.selection.channel.open.tooltip.details="Open channel in external application handling the HTTPS protocol."
|
||||
|
|
@ -1403,6 +1408,11 @@ AdvSceneSwitcher.tempVar.twitch.user_login.removeMod.description="The user login
|
|||
AdvSceneSwitcher.tempVar.twitch.user_name.removeMod="User name"
|
||||
AdvSceneSwitcher.tempVar.twitch.user_name.removeMod.description="The display name of the removed moderator."
|
||||
|
||||
AdvSceneSwitcher.tempVar.twitch.chatter="User login"
|
||||
AdvSceneSwitcher.tempVar.twitch.chatter.description="The user login of the person who sent the chat message."
|
||||
AdvSceneSwitcher.tempVar.twitch.chat_message="Chat message"
|
||||
|
||||
|
||||
AdvSceneSwitcher.tempVar.audio.output_volume="Output volume"
|
||||
AdvSceneSwitcher.tempVar.audio.output_volume.description="The volume the audio source is outputting."
|
||||
AdvSceneSwitcher.tempVar.audio.configured_volume="Configured volume"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ target_sources(
|
|||
category-selection.hpp
|
||||
channel-selection.cpp
|
||||
channel-selection.hpp
|
||||
chat-connection.cpp
|
||||
chat-connection.hpp
|
||||
event-sub.cpp
|
||||
event-sub.hpp
|
||||
macro-action-twitch.cpp
|
||||
|
|
|
|||
716
src/macro-external/twitch/chat-connection.cpp
Normal file
716
src/macro-external/twitch/chat-connection.cpp
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
#include "chat-connection.hpp"
|
||||
#include "token.hpp"
|
||||
#include "twitch-helpers.hpp"
|
||||
|
||||
#include <log-helper.hpp>
|
||||
#include <switcher-data.hpp>
|
||||
|
||||
namespace advss {
|
||||
|
||||
using websocketpp::lib::placeholders::_1;
|
||||
using websocketpp::lib::placeholders::_2;
|
||||
using websocketpp::lib::bind;
|
||||
|
||||
static constexpr int reconnectDelay = 15;
|
||||
static constexpr int messageIDBufferSize = 20;
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
static ParsedTags parseTags(const std::string &tags)
|
||||
{
|
||||
ParsedTags parsedTags;
|
||||
static constexpr std::array<std::string_view, 2> tagsToIgnore = {
|
||||
"client-nonce", "flags"};
|
||||
|
||||
std::vector<std::string> parsedTagPairs;
|
||||
size_t pos = 0;
|
||||
size_t endPos;
|
||||
|
||||
while ((endPos = tags.find(';', pos)) != std::string::npos) {
|
||||
parsedTagPairs.push_back(tags.substr(pos, endPos - pos));
|
||||
pos = endPos + 1;
|
||||
}
|
||||
|
||||
parsedTagPairs.push_back(tags.substr(pos));
|
||||
|
||||
for (const auto &tagPair : parsedTagPairs) {
|
||||
size_t equalsPos = tagPair.find('=');
|
||||
std::string tagName = tagPair.substr(0, equalsPos);
|
||||
std::string tagValue = (equalsPos != std::string::npos)
|
||||
? tagPair.substr(equalsPos + 1)
|
||||
: "";
|
||||
|
||||
if (std::find(tagsToIgnore.begin(), tagsToIgnore.end(),
|
||||
tagName) != tagsToIgnore.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagValue.empty()) {
|
||||
parsedTags.tagMap[tagName] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagName == "badges" || tagName == "badge-info") {
|
||||
ParsedTags::BadgeMap badgeMap;
|
||||
std::vector<std::string> badgePairs;
|
||||
size_t badgePos = 0;
|
||||
size_t badgeEndPos;
|
||||
|
||||
while ((badgeEndPos = tagValue.find(',', badgePos)) !=
|
||||
std::string::npos) {
|
||||
badgePairs.push_back(tagValue.substr(
|
||||
badgePos, badgeEndPos - badgePos));
|
||||
badgePos = badgeEndPos + 1;
|
||||
}
|
||||
|
||||
badgePairs.push_back(tagValue.substr(badgePos));
|
||||
|
||||
for (const auto &badgePair : badgePairs) {
|
||||
size_t slashPos = badgePair.find('/');
|
||||
std::string badgeName =
|
||||
badgePair.substr(0, slashPos);
|
||||
std::string badgeValue =
|
||||
(slashPos != std::string::npos)
|
||||
? badgePair.substr(slashPos + 1)
|
||||
: "";
|
||||
|
||||
badgeMap[badgeName] = badgeValue;
|
||||
}
|
||||
|
||||
parsedTags.tagMap[tagName] = badgeMap;
|
||||
|
||||
} else if (tagName == "emotes") {
|
||||
ParsedTags::EmoteMap emoteMap;
|
||||
std::vector<std::string> emotePairs;
|
||||
size_t emotePos = 0;
|
||||
size_t emoteEndPos;
|
||||
|
||||
while ((emoteEndPos = tagValue.find('/', emotePos)) !=
|
||||
std::string::npos) {
|
||||
emotePairs.push_back(tagValue.substr(
|
||||
emotePos, emoteEndPos - emotePos));
|
||||
emotePos = emoteEndPos + 1;
|
||||
}
|
||||
|
||||
emotePairs.push_back(tagValue.substr(emotePos));
|
||||
|
||||
for (const auto &emotePair : emotePairs) {
|
||||
size_t colonPos = emotePair.find(':');
|
||||
std::string emoteId =
|
||||
emotePair.substr(0, colonPos);
|
||||
std::string positions =
|
||||
(colonPos != std::string::npos)
|
||||
? emotePair.substr(colonPos + 1)
|
||||
: "";
|
||||
|
||||
std::vector<std::pair<int, int>> textPositions;
|
||||
size_t positionPos = 0;
|
||||
size_t positionEndPos;
|
||||
|
||||
while ((positionEndPos = positions.find(
|
||||
',', positionPos)) !=
|
||||
std::string::npos) {
|
||||
std::string position = positions.substr(
|
||||
positionPos,
|
||||
positionEndPos - positionPos);
|
||||
size_t dashPos = position.find('-');
|
||||
int startPos = std::stoi(
|
||||
position.substr(0, dashPos));
|
||||
int endPos = std::stoi(
|
||||
position.substr(dashPos + 1));
|
||||
textPositions.push_back(
|
||||
{startPos, endPos});
|
||||
positionPos = positionEndPos + 1;
|
||||
}
|
||||
|
||||
textPositions.push_back(
|
||||
{std::stoi(
|
||||
positions.substr(positionPos)),
|
||||
std::stoi(positions.substr(
|
||||
positionPos))});
|
||||
emoteMap[emoteId] = textPositions;
|
||||
}
|
||||
|
||||
parsedTags.tagMap[tagName] = emoteMap;
|
||||
} else if (tagName == "emote-sets") {
|
||||
ParsedTags::EmoteSet emoteSetIds;
|
||||
size_t setIdPos = 0;
|
||||
size_t setIdEndPos;
|
||||
|
||||
while ((setIdEndPos = tagValue.find(',', setIdPos)) !=
|
||||
std::string::npos) {
|
||||
emoteSetIds.push_back(tagValue.substr(
|
||||
setIdPos, setIdEndPos - setIdPos));
|
||||
setIdPos = setIdEndPos + 1;
|
||||
}
|
||||
|
||||
emoteSetIds.push_back(tagValue.substr(setIdPos));
|
||||
|
||||
parsedTags.tagMap[tagName] = emoteSetIds;
|
||||
} else {
|
||||
parsedTags.tagMap[tagName] = tagValue;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedTags;
|
||||
}
|
||||
|
||||
static void parseSource(const std::string &rawSourceComponent,
|
||||
IRCMessage &message)
|
||||
{
|
||||
if (rawSourceComponent.empty()) {
|
||||
return;
|
||||
}
|
||||
size_t nickPos = 0;
|
||||
size_t nickEndPos = rawSourceComponent.find('!');
|
||||
|
||||
if (nickEndPos != std::string::npos) {
|
||||
message.source.nick = rawSourceComponent.substr(
|
||||
nickPos, nickEndPos - nickPos);
|
||||
message.source.host = rawSourceComponent.substr(nickEndPos + 1);
|
||||
} else {
|
||||
// Assume the entire source is the host if no '!' is found
|
||||
message.source.host = rawSourceComponent;
|
||||
}
|
||||
}
|
||||
|
||||
static void parseCommand(const std::string &rawCommandComponent,
|
||||
IRCMessage &message)
|
||||
{
|
||||
std::vector<std::string> commandParts;
|
||||
size_t pos = 0;
|
||||
size_t endPos;
|
||||
|
||||
while ((endPos = rawCommandComponent.find(' ', pos)) !=
|
||||
std::string::npos) {
|
||||
commandParts.push_back(
|
||||
rawCommandComponent.substr(pos, endPos - pos));
|
||||
pos = endPos + 1;
|
||||
}
|
||||
|
||||
commandParts.push_back(rawCommandComponent.substr(pos));
|
||||
|
||||
if (commandParts.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
message.command.command = commandParts[0];
|
||||
if (message.command.command == "CAP") {
|
||||
if (commandParts.size() < 3) {
|
||||
return;
|
||||
}
|
||||
message.command.parameters = (commandParts[2] == "ACK");
|
||||
} else if (message.command.command == "RECONNECT") {
|
||||
blog(LOG_INFO,
|
||||
"The Twitch IRC server is about to terminate the connection for maintenance.");
|
||||
} else if (message.command.command == "421") {
|
||||
// Unsupported command
|
||||
return;
|
||||
} else if (message.command.command == "PING" ||
|
||||
message.command.command == "001" ||
|
||||
message.command.command == "JOIN" ||
|
||||
message.command.command == "PART" ||
|
||||
message.command.command == "NOTICE" ||
|
||||
message.command.command == "CLEARCHAT" ||
|
||||
message.command.command == "HOSTTARGET" ||
|
||||
message.command.command == "PRIVMSG") {
|
||||
if (commandParts.size() < 2) {
|
||||
return;
|
||||
}
|
||||
message.command.parameters = commandParts[1];
|
||||
} else if (message.command.command == "GLOBALUSERSTATE" ||
|
||||
message.command.command == "USERSTATE" ||
|
||||
message.command.command == "ROOMSTATE" ||
|
||||
message.command.command == "002" ||
|
||||
message.command.command == "003" ||
|
||||
message.command.command == "004" ||
|
||||
message.command.command == "353" ||
|
||||
message.command.command == "366" ||
|
||||
message.command.command == "372" ||
|
||||
message.command.command == "375" ||
|
||||
message.command.command == "376") {
|
||||
// Do nothing for these cases for now
|
||||
} else {
|
||||
vblog(LOG_INFO, "Unexpected IRC command: %s",
|
||||
message.command.command.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
static IRCMessage parseMessage(const std::string &message)
|
||||
{
|
||||
IRCMessage parsedMessage;
|
||||
|
||||
size_t idx = 0;
|
||||
std::string rawTagsComponent;
|
||||
std::string rawSourceComponent;
|
||||
std::string rawCommandComponent;
|
||||
std::string rawMessageComponent;
|
||||
|
||||
if (message[idx] == '@') {
|
||||
size_t endIdx = message.find(' ');
|
||||
rawTagsComponent = message.substr(1, endIdx - 1);
|
||||
idx = endIdx + 1;
|
||||
}
|
||||
|
||||
if (message[idx] == ':') {
|
||||
idx += 1;
|
||||
size_t endIdx = message.find(' ', idx);
|
||||
rawSourceComponent = message.substr(idx, endIdx - idx);
|
||||
idx = endIdx + 1;
|
||||
}
|
||||
|
||||
size_t endIdx = message.find(':', idx);
|
||||
if (endIdx == std::string::npos) {
|
||||
endIdx = message.length();
|
||||
}
|
||||
|
||||
rawCommandComponent = message.substr(idx, endIdx - idx);
|
||||
if (endIdx != message.length()) {
|
||||
idx = endIdx + 1;
|
||||
rawMessageComponent = message.substr(idx);
|
||||
}
|
||||
|
||||
parsedMessage.tags = parseTags(rawTagsComponent);
|
||||
parseSource(rawSourceComponent, parsedMessage);
|
||||
parseCommand(rawCommandComponent, parsedMessage);
|
||||
parsedMessage.message = rawMessageComponent;
|
||||
return parsedMessage;
|
||||
}
|
||||
|
||||
static std::vector<IRCMessage> parseMessages(const std::string &messagesStr)
|
||||
{
|
||||
static constexpr std::string_view delimiter = "\r\n";
|
||||
std::vector<IRCMessage> messages;
|
||||
|
||||
auto isWhitespace = [](const std::string &str) -> bool {
|
||||
for (char c : str) {
|
||||
if (!std::isspace(static_cast<unsigned char>(c))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
size_t start = 0;
|
||||
size_t end = messagesStr.find(delimiter);
|
||||
while (end != std::string::npos) {
|
||||
auto message = messagesStr.substr(start, end - start);
|
||||
if (isWhitespace(message)) {
|
||||
continue;
|
||||
}
|
||||
auto parsedMessage = parseMessage(message);
|
||||
if (parsedMessage.command.command.empty()) {
|
||||
vblog(LOG_INFO, "discarding IRC message: %s",
|
||||
message.c_str());
|
||||
} else {
|
||||
messages.emplace_back(parsedMessage);
|
||||
}
|
||||
start = end + delimiter.length();
|
||||
end = messagesStr.find(delimiter, start);
|
||||
}
|
||||
auto message = messagesStr.substr(start);
|
||||
if (isWhitespace(message)) {
|
||||
return messages;
|
||||
}
|
||||
auto parsedMessage = parseMessage(message);
|
||||
if (parsedMessage.command.command.empty()) {
|
||||
vblog(LOG_INFO, "discarding IRC message: %s", message.c_str());
|
||||
return messages;
|
||||
}
|
||||
messages.emplace_back(parsedMessage);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
static constexpr std::string_view defaultURL =
|
||||
"wss://irc-ws.chat.twitch.tv:443";
|
||||
|
||||
std::map<TwitchChatConnection::ChatMapKey, std::weak_ptr<TwitchChatConnection>>
|
||||
TwitchChatConnection::_chatMap = {};
|
||||
|
||||
TwitchChatConnection::TwitchChatConnection(const TwitchToken &token,
|
||||
const TwitchChannel &channel)
|
||||
: QObject(nullptr), _token(token), _channel(channel)
|
||||
{
|
||||
_client.get_alog().clear_channels(
|
||||
websocketpp::log::alevel::frame_header |
|
||||
websocketpp::log::alevel::frame_payload |
|
||||
websocketpp::log::alevel::control);
|
||||
_client.init_asio();
|
||||
#ifndef _WIN32
|
||||
_client.set_reuse_addr(true);
|
||||
#endif
|
||||
|
||||
_client.set_open_handler(bind(&TwitchChatConnection::OnOpen, this, _1));
|
||||
_client.set_message_handler(
|
||||
bind(&TwitchChatConnection::OnMessage, this, _1, _2));
|
||||
_client.set_close_handler(
|
||||
bind(&TwitchChatConnection::OnClose, this, _1));
|
||||
_client.set_fail_handler(bind(&TwitchChatConnection::OnFail, this, _1));
|
||||
_client.set_tls_init_handler([](websocketpp::connection_hdl) {
|
||||
return websocketpp::lib::make_shared<asio::ssl::context>(
|
||||
asio::ssl::context::sslv23_client);
|
||||
});
|
||||
_url = defaultURL.data();
|
||||
RegisterInstance();
|
||||
}
|
||||
|
||||
TwitchChatConnection::~TwitchChatConnection()
|
||||
{
|
||||
Disconnect();
|
||||
UnregisterInstance();
|
||||
}
|
||||
|
||||
static bool setupChatMessageClear()
|
||||
{
|
||||
GetSwitcher()->AddIntervalResetStep(
|
||||
&TwitchChatConnection::ClearAllMessages);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TwitchChatConnection::_setupDone = setupChatMessageClear();
|
||||
|
||||
void TwitchChatConnection::ClearMessages()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
_messages.clear();
|
||||
}
|
||||
|
||||
std::mutex TwitchChatConnection::_instancesMtx;
|
||||
std::vector<TwitchChatConnection *> TwitchChatConnection::_instances;
|
||||
|
||||
void TwitchChatConnection::RegisterInstance()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_instancesMtx);
|
||||
_instances.emplace_back(this);
|
||||
}
|
||||
|
||||
void TwitchChatConnection::UnregisterInstance()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_instancesMtx);
|
||||
auto it = std::remove(_instances.begin(), _instances.end(), this);
|
||||
_instances.erase(it, _instances.end());
|
||||
}
|
||||
|
||||
std::shared_ptr<TwitchChatConnection>
|
||||
TwitchChatConnection::GetChatConnection(const TwitchToken &token_,
|
||||
const TwitchChannel &channel)
|
||||
{
|
||||
auto token = token_.GetToken();
|
||||
if (!token ||
|
||||
!token_.AnyOptionIsEnabled({{"chat:read"}, {"chat:edit"}}) ||
|
||||
channel.GetName().empty()) {
|
||||
return {};
|
||||
}
|
||||
auto key = ChatMapKey{channel.GetName(), *token};
|
||||
auto it = _chatMap.find(key);
|
||||
if (it != _chatMap.end()) {
|
||||
auto connection = it->second.lock();
|
||||
if (connection) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
auto connection = std::shared_ptr<TwitchChatConnection>(
|
||||
new TwitchChatConnection(token_, channel));
|
||||
_chatMap[key] = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
void TwitchChatConnection::ClearAllMessages()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_instancesMtx);
|
||||
for (const auto &TwitchChatConnection : _instances) {
|
||||
TwitchChatConnection->ClearMessages();
|
||||
}
|
||||
}
|
||||
|
||||
void TwitchChatConnection::ConnectThread()
|
||||
{
|
||||
while (!_disconnect) {
|
||||
std::unique_lock<std::mutex> lock(_waitMtx);
|
||||
_client.reset();
|
||||
_connected = true;
|
||||
websocketpp::lib::error_code ec;
|
||||
EventSubWSClient::connection_ptr con =
|
||||
_client.get_connection(_url, ec);
|
||||
if (ec) {
|
||||
blog(LOG_INFO, "Twitch TwitchChatConnection failed: %s",
|
||||
ec.message().c_str());
|
||||
} else {
|
||||
_client.connect(con);
|
||||
_connection = connection_hdl(con);
|
||||
_client.run();
|
||||
}
|
||||
|
||||
blog(LOG_INFO,
|
||||
"Twitch TwitchChatConnection trying to reconnect to in %d seconds.",
|
||||
reconnectDelay);
|
||||
_cv.wait_for(lock, std::chrono::seconds(reconnectDelay));
|
||||
}
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
void TwitchChatConnection::Connect()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_connectMtx);
|
||||
if (_connected) {
|
||||
vblog(LOG_INFO,
|
||||
"Twitch TwitchChatConnection connect already in progress");
|
||||
return;
|
||||
}
|
||||
_disconnect = true;
|
||||
if (_thread.joinable()) {
|
||||
_thread.join();
|
||||
}
|
||||
_disconnect = false;
|
||||
_thread = std::thread(&TwitchChatConnection::ConnectThread, this);
|
||||
}
|
||||
|
||||
void TwitchChatConnection::Disconnect()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_connectMtx);
|
||||
_disconnect = true;
|
||||
websocketpp::lib::error_code ec;
|
||||
_client.close(_connection, websocketpp::close::status::normal,
|
||||
"Twitch chat connection stopping", ec);
|
||||
{
|
||||
std::unique_lock<std::mutex> waitLock(_waitMtx);
|
||||
_cv.notify_all();
|
||||
}
|
||||
|
||||
while (_connected) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
_client.close(_connection, websocketpp::close::status::normal,
|
||||
"Twitch chat connection stopping", ec);
|
||||
}
|
||||
|
||||
if (_thread.joinable()) {
|
||||
_thread.join();
|
||||
}
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
static std::string toLowerCase(const std::string &str)
|
||||
{
|
||||
std::string result;
|
||||
result.reserve(str.length());
|
||||
|
||||
for (char c : str) {
|
||||
result.push_back(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<IRCMessage> TwitchChatConnection::Messages()
|
||||
{
|
||||
if (!_connected) {
|
||||
Connect();
|
||||
return {};
|
||||
}
|
||||
|
||||
if (_joinedChannelName !=
|
||||
"#" + toLowerCase(std::string(_channel.GetName()))) {
|
||||
// TODO: disconnect previous channel?
|
||||
JoinChannel(_channel.GetName());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
return _messages;
|
||||
}
|
||||
|
||||
std::vector<IRCMessage> TwitchChatConnection::Whispers()
|
||||
{
|
||||
if (!_connected) {
|
||||
Connect();
|
||||
return {};
|
||||
}
|
||||
|
||||
if (_joinedChannelName !=
|
||||
"#" + toLowerCase(std::string(_channel.GetName()))) {
|
||||
// TODO: disconnect previous channel?
|
||||
JoinChannel(_channel.GetName());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
return _whispers;
|
||||
}
|
||||
|
||||
void TwitchChatConnection::SendChatMessage(const std::string &message)
|
||||
{
|
||||
if (!_connected) {
|
||||
Connect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_joinedChannelName !=
|
||||
"#" + toLowerCase(std::string(_channel.GetName()))) {
|
||||
// TODO: disconnect previous channel?
|
||||
JoinChannel(_channel.GetName());
|
||||
return;
|
||||
}
|
||||
Send("PRIVMSG " + _joinedChannelName + " :" + message);
|
||||
}
|
||||
|
||||
void TwitchChatConnection::Authenticate()
|
||||
{
|
||||
if (!_token.GetToken()) {
|
||||
blog(LOG_INFO,
|
||||
"Joining Twitch chat failed due to missing token!");
|
||||
}
|
||||
|
||||
Send("CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands");
|
||||
auto pass = _token.GetToken();
|
||||
if (!pass.has_value()) {
|
||||
blog(LOG_INFO,
|
||||
"Joining Twitch chat failed due to invalid token!");
|
||||
}
|
||||
Send("PASS oauth:" + *pass);
|
||||
Send("NICK " + _token.GetName());
|
||||
}
|
||||
|
||||
void TwitchChatConnection::JoinChannel(const std::string &channelName)
|
||||
{
|
||||
if (!_authenticated) {
|
||||
return;
|
||||
}
|
||||
Send("JOIN #" + channelName);
|
||||
}
|
||||
|
||||
void TwitchChatConnection::HandleJoin(const IRCMessage &message)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
_joinedChannelName = std::get<std::string>(message.command.parameters);
|
||||
vblog(LOG_INFO, "Twitch chat join was successful!");
|
||||
}
|
||||
|
||||
void TwitchChatConnection::HandleNewMessage(const IRCMessage &message)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
_messages.emplace_back(message);
|
||||
vblog(LOG_INFO, "Received new chat message %s",
|
||||
message.message.c_str());
|
||||
}
|
||||
|
||||
void TwitchChatConnection::HandleWhisper(const IRCMessage &message)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_messageMtx);
|
||||
_whispers.emplace_back(message);
|
||||
vblog(LOG_INFO, "Received new chat whisper message %s",
|
||||
message.message.c_str());
|
||||
}
|
||||
|
||||
void TwitchChatConnection::HandleNotice(const IRCMessage &message) const
|
||||
{
|
||||
if (message.message == "Login unsuccessful") {
|
||||
blog(LOG_INFO, "Twitch chat connection was unsuccessful: %s",
|
||||
message.message.c_str());
|
||||
return;
|
||||
} else if (message.message ==
|
||||
"You don't have permission to perform that action") {
|
||||
blog(LOG_INFO,
|
||||
"No permission. Check if the access token is still valid");
|
||||
return;
|
||||
}
|
||||
|
||||
vblog(LOG_INFO, "Twitch chat notice: %s", message.message.c_str());
|
||||
}
|
||||
|
||||
void TwitchChatConnection::HandleReconnect()
|
||||
{
|
||||
blog(LOG_INFO,
|
||||
"Received RECONNECT notice! Twitch chat connection will be terminated!");
|
||||
Disconnect();
|
||||
}
|
||||
|
||||
void TwitchChatConnection::OnOpen(connection_hdl)
|
||||
{
|
||||
vblog(LOG_INFO, "Twitch chat connection opened");
|
||||
_connected = true;
|
||||
|
||||
Authenticate();
|
||||
}
|
||||
|
||||
void TwitchChatConnection::OnMessage(connection_hdl,
|
||||
EventSubWSClient::message_ptr message)
|
||||
{
|
||||
constexpr std::string_view authOKCommand = "001";
|
||||
constexpr std::string_view pingCommand = "PING";
|
||||
constexpr std::string_view joinOKCommand = "JOIN";
|
||||
constexpr std::string_view noticeCommand = "NOTICE";
|
||||
constexpr std::string_view reconnectCommand = "RECONNECT";
|
||||
constexpr std::string_view newMessageCommand = "PRIVMSG";
|
||||
constexpr std::string_view whisperCommand = "WHISPER";
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message->get_opcode() != websocketpp::frame::opcode::text) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string payload = message->get_payload();
|
||||
auto messages = parseMessages(payload);
|
||||
|
||||
for (const auto &message : messages) {
|
||||
if (message.command.command == authOKCommand) {
|
||||
vblog(LOG_INFO,
|
||||
"Twitch chat connection authenticated!");
|
||||
_authenticated = true;
|
||||
} else if (message.command.command == pingCommand) {
|
||||
Send("PONG " +
|
||||
std::get<std::string>(message.command.parameters));
|
||||
} else if (message.command.command == joinOKCommand) {
|
||||
HandleJoin(message);
|
||||
} else if (message.command.command == newMessageCommand) {
|
||||
HandleNewMessage(message);
|
||||
} else if (message.command.command == whisperCommand) {
|
||||
HandleWhisper(message);
|
||||
} else if (message.command.command == noticeCommand) {
|
||||
HandleNotice(message);
|
||||
} else if (message.command.command == reconnectCommand) {
|
||||
HandleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TwitchChatConnection::OnClose(connection_hdl hdl)
|
||||
{
|
||||
EventSubWSClient::connection_ptr con = _client.get_con_from_hdl(hdl);
|
||||
auto msg = con->get_ec().message();
|
||||
blog(LOG_INFO, "Twitch chat connection closed: %s", msg.c_str());
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
void TwitchChatConnection::OnFail(connection_hdl hdl)
|
||||
{
|
||||
EventSubWSClient::connection_ptr con = _client.get_con_from_hdl(hdl);
|
||||
auto msg = con->get_ec().message();
|
||||
blog(LOG_INFO, "Twitch chat connection failed: %s", msg.c_str());
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
void TwitchChatConnection::Send(const std::string &msg)
|
||||
{
|
||||
if (_connection.expired()) {
|
||||
return;
|
||||
}
|
||||
websocketpp::lib::error_code errorCode;
|
||||
_client.send(_connection, msg, websocketpp::frame::opcode::text,
|
||||
errorCode);
|
||||
if (errorCode) {
|
||||
std::string errorCodeMessage = errorCode.message();
|
||||
blog(LOG_INFO, "Twitch chat websocket send failed: %s",
|
||||
errorCodeMessage.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool TwitchChatConnection::ChatMapKey::operator<(const ChatMapKey &other) const
|
||||
{
|
||||
return (channelName + token) < (other.channelName + other.token);
|
||||
}
|
||||
|
||||
} // namespace advss
|
||||
114
src/macro-external/twitch/chat-connection.hpp
Normal file
114
src/macro-external/twitch/chat-connection.hpp
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#pragma once
|
||||
#include "channel-selection.hpp"
|
||||
#include "token.hpp"
|
||||
|
||||
#include <websocketpp/client.hpp>
|
||||
#include <QObject>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
|
||||
#include <websocketpp/config/asio_client.hpp>
|
||||
#include <websocketpp/config/asio.hpp>
|
||||
|
||||
namespace advss {
|
||||
|
||||
using websocketpp::connection_hdl;
|
||||
|
||||
struct ParsedTags {
|
||||
using BadgeMap = std::unordered_map<std::string, std::string>;
|
||||
using EmoteMap = std::unordered_map<std::string,
|
||||
std::vector<std::pair<int, int>>>;
|
||||
using EmoteSet = std::vector<std::string>;
|
||||
std::unordered_map<std::string, std::variant<std::string, BadgeMap,
|
||||
EmoteMap, EmoteSet>>
|
||||
tagMap;
|
||||
};
|
||||
|
||||
struct IRCMessage {
|
||||
ParsedTags tags;
|
||||
struct {
|
||||
std::string nick;
|
||||
std::string host;
|
||||
} source;
|
||||
struct {
|
||||
std::string command;
|
||||
std::variant<std::string, bool, std::vector<std::string>>
|
||||
parameters;
|
||||
} command;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
class TwitchChatConnection : public QObject {
|
||||
public:
|
||||
~TwitchChatConnection();
|
||||
|
||||
static std::shared_ptr<TwitchChatConnection>
|
||||
GetChatConnection(const TwitchToken &token,
|
||||
const TwitchChannel &channel);
|
||||
std::vector<IRCMessage> Messages();
|
||||
std::vector<IRCMessage> Whispers();
|
||||
void SendChatMessage(const std::string &message);
|
||||
|
||||
static void ClearAllMessages();
|
||||
void ClearMessages();
|
||||
|
||||
private:
|
||||
TwitchChatConnection(const TwitchToken &token,
|
||||
const TwitchChannel &channel);
|
||||
|
||||
void Connect();
|
||||
void Disconnect();
|
||||
|
||||
void OnOpen(connection_hdl hdl);
|
||||
void
|
||||
OnMessage(connection_hdl hdl,
|
||||
websocketpp::client<websocketpp::config::asio_tls_client>::
|
||||
message_ptr message);
|
||||
void OnClose(connection_hdl hdl);
|
||||
void OnFail(connection_hdl hdl);
|
||||
void Send(const std::string &msg);
|
||||
void ConnectThread();
|
||||
|
||||
void Authenticate();
|
||||
void JoinChannel(const std::string &);
|
||||
void HandleJoin(const IRCMessage &);
|
||||
void HandleNewMessage(const IRCMessage &);
|
||||
void HandleWhisper(const IRCMessage &);
|
||||
void HandleNotice(const IRCMessage &) const;
|
||||
void HandleReconnect();
|
||||
|
||||
void RegisterInstance();
|
||||
void UnregisterInstance();
|
||||
|
||||
struct ChatMapKey {
|
||||
std::string channelName;
|
||||
std::string token;
|
||||
bool operator<(const ChatMapKey &) const;
|
||||
};
|
||||
static std::map<ChatMapKey, std::weak_ptr<TwitchChatConnection>>
|
||||
_chatMap;
|
||||
|
||||
TwitchToken _token;
|
||||
TwitchChannel _channel;
|
||||
std::string _joinedChannelName;
|
||||
|
||||
websocketpp::client<websocketpp::config::asio_tls_client> _client;
|
||||
connection_hdl _connection;
|
||||
std::thread _thread;
|
||||
std::mutex _waitMtx;
|
||||
std::mutex _connectMtx;
|
||||
std::condition_variable _cv;
|
||||
std::atomic_bool _connected{false};
|
||||
std::atomic_bool _authenticated{false};
|
||||
std::atomic_bool _disconnect{false};
|
||||
std::string _url;
|
||||
|
||||
std::mutex _messageMtx;
|
||||
std::vector<IRCMessage> _messages;
|
||||
std::vector<IRCMessage> _whispers;
|
||||
static std::mutex _instancesMtx;
|
||||
static std::vector<TwitchChatConnection *> _instances;
|
||||
static bool _setupDone;
|
||||
};
|
||||
|
||||
} // namespace advss
|
||||
|
|
@ -37,6 +37,8 @@ const static std::map<MacroActionTwitch::Action, std::string> actionTypes = {
|
|||
"AdvSceneSwitcher.action.twitch.type.emoteOnlyDisable"},
|
||||
{MacroActionTwitch::Action::RAID,
|
||||
"AdvSceneSwitcher.action.twitch.type.raid"},
|
||||
{MacroActionTwitch::Action::SEND_CHAT_MESSAGE,
|
||||
"AdvSceneSwitcher.action.twitch.type.sendChatMessage"},
|
||||
};
|
||||
|
||||
const static std::map<MacroActionTwitch::AnnouncementColor, std::string>
|
||||
|
|
@ -221,6 +223,18 @@ void MacroActionTwitch::StartRaid(const std::shared_ptr<TwitchToken> &token)
|
|||
}
|
||||
}
|
||||
|
||||
void MacroActionTwitch::SendChatMessage(
|
||||
const std::shared_ptr<TwitchToken> &token)
|
||||
{
|
||||
if (!_chatConnection) {
|
||||
_chatConnection = TwitchChatConnection::GetChatConnection(
|
||||
*token, _channel);
|
||||
return;
|
||||
}
|
||||
|
||||
_chatConnection->SendChatMessage(_chatMessage);
|
||||
}
|
||||
|
||||
bool MacroActionTwitch::PerformAction()
|
||||
{
|
||||
auto token = _token.lock();
|
||||
|
|
@ -252,9 +266,13 @@ bool MacroActionTwitch::PerformAction()
|
|||
break;
|
||||
case MacroActionTwitch::Action::DISABLE_EMOTE_ONLY:
|
||||
SetChatEmoteOnlyMode(token, false);
|
||||
break;
|
||||
case MacroActionTwitch::Action::RAID:
|
||||
StartRaid(token);
|
||||
break;
|
||||
case MacroActionTwitch::Action::SEND_CHAT_MESSAGE:
|
||||
SendChatMessage(token);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -291,6 +309,7 @@ bool MacroActionTwitch::Save(obs_data_t *obj) const
|
|||
obs_data_set_int(obj, "announcementColor",
|
||||
static_cast<int>(_announcementColor));
|
||||
_channel.Save(obj);
|
||||
_chatMessage.Save(obj, "chatMessage");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -310,6 +329,8 @@ bool MacroActionTwitch::Load(obs_data_t *obj)
|
|||
_announcementColor = static_cast<AnnouncementColor>(
|
||||
obs_data_get_int(obj, "announcementColor"));
|
||||
_channel.Load(obj);
|
||||
_chatMessage.Load(obj, "chatMessage");
|
||||
ResetChatConnection();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -327,6 +348,7 @@ bool MacroActionTwitch::ActionIsSupportedByToken()
|
|||
{Action::DISABLE_EMOTE_ONLY,
|
||||
{"moderator:manage:chat_settings"}},
|
||||
{Action::RAID, {"channel:manage:raids"}},
|
||||
{Action::SEND_CHAT_MESSAGE, {"chat:edit"}},
|
||||
};
|
||||
auto token = _token.lock();
|
||||
if (!token) {
|
||||
|
|
@ -340,6 +362,11 @@ bool MacroActionTwitch::ActionIsSupportedByToken()
|
|||
return token->OptionIsEnabled(option->second);
|
||||
}
|
||||
|
||||
void MacroActionTwitch::ResetChatConnection()
|
||||
{
|
||||
_chatConnection.reset();
|
||||
}
|
||||
|
||||
static inline void populateActionSelection(QComboBox *list)
|
||||
{
|
||||
for (const auto &[action, name] : actionTypes) {
|
||||
|
|
@ -371,7 +398,8 @@ MacroActionTwitchEdit::MacroActionTwitchEdit(
|
|||
_duration(new DurationSelection(this, false, 0)),
|
||||
_announcementMessage(new VariableTextEdit(this)),
|
||||
_announcementColor(new QComboBox(this)),
|
||||
_channel(new TwitchChannelSelection(this))
|
||||
_channel(new TwitchChannelSelection(this)),
|
||||
_chatMessage(new VariableTextEdit(this))
|
||||
{
|
||||
_streamTitle->setSizePolicy(QSizePolicy::MinimumExpanding,
|
||||
QSizePolicy::Preferred);
|
||||
|
|
@ -414,30 +442,21 @@ MacroActionTwitchEdit::MacroActionTwitchEdit(
|
|||
QWidget::connect(_channel,
|
||||
SIGNAL(ChannelChanged(const TwitchChannel &)), this,
|
||||
SLOT(ChannelChanged(const TwitchChannel &)));
|
||||
QWidget::connect(_chatMessage, SIGNAL(textChanged()), this,
|
||||
SLOT(ChatMessageChanged()));
|
||||
|
||||
PlaceWidgets(
|
||||
obs_module_text("AdvSceneSwitcher.action.twitch.entry.line1"),
|
||||
_layout,
|
||||
{{"{{account}}", _tokens},
|
||||
{"{{actions}}", _actions},
|
||||
{"{{streamTitle}}", _streamTitle},
|
||||
{"{{category}}", _category},
|
||||
{"{{markerDescription}}", _markerDescription},
|
||||
{"{{clipHasDelay}}", _clipHasDelay},
|
||||
{"{{duration}}", _duration},
|
||||
{"{{announcementColor}}", _announcementColor},
|
||||
{"{{channel}}", _channel}});
|
||||
_layout->setContentsMargins(0, 0, 0, 0);
|
||||
_entryData = entryData;
|
||||
SetWidgetLayout();
|
||||
|
||||
auto mainLayout = new QVBoxLayout();
|
||||
mainLayout->addLayout(_layout);
|
||||
mainLayout->addWidget(_announcementMessage);
|
||||
mainLayout->addWidget(_chatMessage);
|
||||
mainLayout->addWidget(_tokenPermissionWarning);
|
||||
setLayout(mainLayout);
|
||||
|
||||
_tokenPermissionCheckTimer.start(1000);
|
||||
|
||||
_entryData = entryData;
|
||||
UpdateEntryData();
|
||||
_loading = false;
|
||||
}
|
||||
|
|
@ -459,6 +478,7 @@ void MacroActionTwitchEdit::ActionChanged(int idx)
|
|||
_entryData->_action = static_cast<MacroActionTwitch::Action>(
|
||||
_actions->itemData(idx).toInt());
|
||||
SetupWidgetVisibility();
|
||||
SetWidgetLayout();
|
||||
}
|
||||
|
||||
void MacroActionTwitchEdit::TwitchTokenChanged(const QString &token)
|
||||
|
|
@ -576,9 +596,12 @@ void MacroActionTwitchEdit::SetupWidgetVisibility()
|
|||
_entryData->_action == MacroActionTwitch::Action::ANNOUNCEMENT);
|
||||
_announcementColor->setVisible(_entryData->_action ==
|
||||
MacroActionTwitch::Action::ANNOUNCEMENT);
|
||||
|
||||
_channel->setVisible(_entryData->_action ==
|
||||
MacroActionTwitch::Action::RAID);
|
||||
_channel->setVisible(
|
||||
_entryData->_action == MacroActionTwitch::Action::RAID ||
|
||||
_entryData->_action ==
|
||||
MacroActionTwitch::Action::SEND_CHAT_MESSAGE);
|
||||
_chatMessage->setVisible(_entryData->_action ==
|
||||
MacroActionTwitch::Action::SEND_CHAT_MESSAGE);
|
||||
if (_entryData->_action == MacroActionTwitch::Action::TITLE ||
|
||||
_entryData->_action == MacroActionTwitch::Action::MARKER) {
|
||||
RemoveStretchIfPresent(_layout);
|
||||
|
|
@ -603,6 +626,50 @@ void MacroActionTwitchEdit::ChannelChanged(const TwitchChannel &channel)
|
|||
_entryData->_channel = channel;
|
||||
}
|
||||
|
||||
void MacroActionTwitchEdit::ChatMessageChanged()
|
||||
{
|
||||
if (_loading || !_entryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto lock = LockContext();
|
||||
_entryData->_chatMessage = _chatMessage->toPlainText().toStdString();
|
||||
|
||||
adjustSize();
|
||||
updateGeometry();
|
||||
}
|
||||
|
||||
void MacroActionTwitchEdit::SetWidgetLayout()
|
||||
{
|
||||
static const std::vector<QWidget *> widgets{
|
||||
_tokens, _actions, _streamTitle,
|
||||
_category, _markerDescription, _clipHasDelay,
|
||||
_duration, _announcementColor, _channel};
|
||||
for (auto widget : widgets) {
|
||||
_layout->removeWidget(widget);
|
||||
}
|
||||
ClearLayout(_layout);
|
||||
|
||||
auto layoutText =
|
||||
_entryData->_action ==
|
||||
MacroActionTwitch::Action::SEND_CHAT_MESSAGE
|
||||
? obs_module_text(
|
||||
"AdvSceneSwitcher.action.twitch.entry.chat")
|
||||
: obs_module_text(
|
||||
"AdvSceneSwitcher.action.twitch.entry.default");
|
||||
PlaceWidgets(layoutText, _layout,
|
||||
{{"{{account}}", _tokens},
|
||||
{"{{actions}}", _actions},
|
||||
{"{{streamTitle}}", _streamTitle},
|
||||
{"{{category}}", _category},
|
||||
{"{{markerDescription}}", _markerDescription},
|
||||
{"{{clipHasDelay}}", _clipHasDelay},
|
||||
{"{{duration}}", _duration},
|
||||
{"{{announcementColor}}", _announcementColor},
|
||||
{"{{channel}}", _channel}});
|
||||
_layout->setContentsMargins(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
void MacroActionTwitchEdit::UpdateEntryData()
|
||||
{
|
||||
if (!_entryData) {
|
||||
|
|
@ -623,6 +690,7 @@ void MacroActionTwitchEdit::UpdateEntryData()
|
|||
static_cast<int>(_entryData->_announcementColor));
|
||||
_channel->SetToken(_entryData->_token);
|
||||
_channel->SetChannel(_entryData->_channel);
|
||||
_chatMessage->setPlainText(_entryData->_chatMessage);
|
||||
|
||||
SetupWidgetVisibility();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include "token.hpp"
|
||||
#include "category-selection.hpp"
|
||||
#include "channel-selection.hpp"
|
||||
#include "chat-connection.hpp"
|
||||
|
||||
#include <variable-line-edit.hpp>
|
||||
#include <variable-text-edit.hpp>
|
||||
|
|
@ -30,6 +31,7 @@ public:
|
|||
ENABLE_EMOTE_ONLY = 60,
|
||||
DISABLE_EMOTE_ONLY = 70,
|
||||
RAID = 80,
|
||||
SEND_CHAT_MESSAGE = 90,
|
||||
};
|
||||
|
||||
enum class AnnouncementColor {
|
||||
|
|
@ -45,6 +47,7 @@ public:
|
|||
bool Save(obs_data_t *obj) const;
|
||||
bool Load(obs_data_t *obj);
|
||||
bool ActionIsSupportedByToken();
|
||||
void ResetChatConnection();
|
||||
|
||||
Action _action = Action::TITLE;
|
||||
std::weak_ptr<TwitchToken> _token;
|
||||
|
|
@ -59,6 +62,7 @@ public:
|
|||
"AdvSceneSwitcher.action.twitch.announcement.message");
|
||||
AnnouncementColor _announcementColor = AnnouncementColor::PRIMARY;
|
||||
TwitchChannel _channel;
|
||||
StringVariable _chatMessage;
|
||||
|
||||
private:
|
||||
void SetStreamTitle(const std::shared_ptr<TwitchToken> &) const;
|
||||
|
|
@ -70,6 +74,9 @@ private:
|
|||
void SetChatEmoteOnlyMode(const std::shared_ptr<TwitchToken> &,
|
||||
bool enable) const;
|
||||
void StartRaid(const std::shared_ptr<TwitchToken> &);
|
||||
void SendChatMessage(const std::shared_ptr<TwitchToken> &);
|
||||
|
||||
std::shared_ptr<TwitchChatConnection> _chatConnection;
|
||||
|
||||
static bool _registered;
|
||||
static const std::string id;
|
||||
|
|
@ -103,6 +110,7 @@ private slots:
|
|||
void AnnouncementMessageChanged();
|
||||
void AnnouncementColorChanged(int index);
|
||||
void ChannelChanged(const TwitchChannel &);
|
||||
void ChatMessageChanged();
|
||||
|
||||
signals:
|
||||
void HeaderInfoChanged(const QString &);
|
||||
|
|
@ -112,8 +120,7 @@ protected:
|
|||
|
||||
private:
|
||||
void SetupWidgetVisibility();
|
||||
|
||||
bool _loading = true;
|
||||
void SetWidgetLayout();
|
||||
|
||||
QHBoxLayout *_layout;
|
||||
FilterComboBox *_actions;
|
||||
|
|
@ -128,6 +135,9 @@ private:
|
|||
VariableTextEdit *_announcementMessage;
|
||||
QComboBox *_announcementColor;
|
||||
TwitchChannelSelection *_channel;
|
||||
VariableTextEdit *_chatMessage;
|
||||
|
||||
bool _loading = true;
|
||||
};
|
||||
|
||||
} // namespace advss
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ const static std::map<MacroConditionTwitch::Condition, std::string> conditionTyp
|
|||
"AdvSceneSwitcher.condition.twitch.type.polling.channel.title"},
|
||||
{MacroConditionTwitch::Condition::CATEGORY_POLLING,
|
||||
"AdvSceneSwitcher.condition.twitch.type.polling.channel.category"},
|
||||
{MacroConditionTwitch::Condition::CHAT_MESSAGE_RECEIVED,
|
||||
"AdvSceneSwitcher.condition.twitch.type.chat.message"},
|
||||
};
|
||||
|
||||
const static std::map<MacroConditionTwitch::Condition, std::string> eventIdentifiers = {
|
||||
|
|
@ -240,6 +242,11 @@ void MacroConditionTwitch::SetPointsReward(
|
|||
ResetSubscription();
|
||||
}
|
||||
|
||||
void MacroConditionTwitch::ResetChatConnection()
|
||||
{
|
||||
_chatConnection.reset();
|
||||
}
|
||||
|
||||
static void
|
||||
setTempVarsHelper(const std::string &jsonStr,
|
||||
std::function<void(const char *, const char *)> setVar)
|
||||
|
|
@ -332,21 +339,40 @@ bool MacroConditionTwitch::CheckChannelLiveEvents(TwitchToken &token)
|
|||
return false;
|
||||
}
|
||||
|
||||
static bool titleMatches(const RegexConfig &conf, const std::string &title,
|
||||
const std::string &expr)
|
||||
static bool stringMatches(const RegexConfig &conf, const std::string &string,
|
||||
const std::string &expr)
|
||||
{
|
||||
if (!conf.Enabled()) {
|
||||
return title == expr;
|
||||
return string == expr;
|
||||
}
|
||||
|
||||
auto regex = conf.GetRegularExpression(expr);
|
||||
if (!regex.isValid()) {
|
||||
return false;
|
||||
}
|
||||
auto match = regex.match(QString::fromStdString(title));
|
||||
auto match = regex.match(QString::fromStdString(string));
|
||||
return match.hasMatch();
|
||||
}
|
||||
|
||||
bool MacroConditionTwitch::CheckChatMessages(TwitchToken &token)
|
||||
{
|
||||
if (!_chatConnection) {
|
||||
_chatConnection = TwitchChatConnection::GetChatConnection(
|
||||
token, _channel);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto messages = _chatConnection->Messages();
|
||||
for (const auto &message : messages) {
|
||||
if (stringMatches(_regexChat, message.message, _chatMessage)) {
|
||||
SetTempVarValue("chatter", message.source.nick);
|
||||
SetTempVarValue("chat_message", message.message);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MacroConditionTwitch::SetTempVarValues(const ChannelLiveInfo &info)
|
||||
{
|
||||
SetTempVarValue("broadcaster_user_id", info.user_id);
|
||||
|
|
@ -487,7 +513,7 @@ bool MacroConditionTwitch::CheckCondition()
|
|||
}
|
||||
SetVariableValue(info->title);
|
||||
SetTempVarValues(*info);
|
||||
return titleMatches(_regex, info->title, _streamTitle);
|
||||
return stringMatches(_regexTitle, info->title, _streamTitle);
|
||||
}
|
||||
case Condition::CATEGORY_POLLING: {
|
||||
auto info = _channel.GetInfo(*token);
|
||||
|
|
@ -498,6 +524,13 @@ bool MacroConditionTwitch::CheckCondition()
|
|||
SetTempVarValues(*info);
|
||||
return info->game_id == std::to_string(_category.id);
|
||||
}
|
||||
case Condition::CHAT_MESSAGE_RECEIVED: {
|
||||
auto token = _token.lock();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return CheckChatMessages(*token);
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -515,7 +548,9 @@ bool MacroConditionTwitch::Save(obs_data_t *obj) const
|
|||
_channel.Save(obj);
|
||||
_pointsReward.Save(obj);
|
||||
_streamTitle.Save(obj, "streamTitle");
|
||||
_regex.Save(obj);
|
||||
_regexTitle.Save(obj, "regexTitle");
|
||||
_chatMessage.Save(obj, "chatMessage");
|
||||
_regexChat.Save(obj, "regexChat");
|
||||
_category.Save(obj);
|
||||
|
||||
return true;
|
||||
|
|
@ -527,12 +562,16 @@ bool MacroConditionTwitch::Load(obs_data_t *obj)
|
|||
|
||||
_condition = static_cast<Condition>(obs_data_get_int(obj, "condition"));
|
||||
_token = GetWeakTwitchTokenByName(obs_data_get_string(obj, "token"));
|
||||
_streamTitle.Load(obj, "streamTitle");
|
||||
_channel.Load(obj);
|
||||
_pointsReward.Load(obj);
|
||||
_regex.Load(obj);
|
||||
_streamTitle.Load(obj, "streamTitle");
|
||||
_regexTitle.Load(obj, "regexTitle");
|
||||
_chatMessage.Load(obj, "chatMessage");
|
||||
_regexChat.Load(obj, "regexChat");
|
||||
_category.Load(obj);
|
||||
|
||||
_subscriptionID = "";
|
||||
ResetChatConnection();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -634,6 +673,8 @@ bool MacroConditionTwitch::ConditionIsSupportedByToken()
|
|||
{Condition::LIVE_POLLING, {}},
|
||||
{Condition::TITLE_POLLING, {}},
|
||||
{Condition::CATEGORY_POLLING, {}},
|
||||
{Condition::CHAT_MESSAGE_RECEIVED,
|
||||
{{"chat:read"}, {"chat:edit"}}},
|
||||
};
|
||||
|
||||
auto token = _token.lock();
|
||||
|
|
@ -825,9 +866,11 @@ void MacroConditionTwitch::SetupTempVars()
|
|||
AddTempvar(id, name.empty() ? id : name, description);
|
||||
};
|
||||
|
||||
setupTempVarHelper("broadcaster_user_id");
|
||||
setupTempVarHelper("broadcaster_user_login");
|
||||
setupTempVarHelper("broadcaster_user_name");
|
||||
if (_condition != Condition::CHAT_MESSAGE_RECEIVED) {
|
||||
setupTempVarHelper("broadcaster_user_id");
|
||||
setupTempVarHelper("broadcaster_user_login");
|
||||
setupTempVarHelper("broadcaster_user_name");
|
||||
}
|
||||
|
||||
switch (_condition) {
|
||||
case Condition::STREAM_ONLINE_LIVE_EVENT:
|
||||
|
|
@ -1122,6 +1165,10 @@ void MacroConditionTwitch::SetupTempVars()
|
|||
setupTempVarHelper("content_classification_labels");
|
||||
setupTempVarHelper("is_branded_content");
|
||||
break;
|
||||
case Condition::CHAT_MESSAGE_RECEIVED:
|
||||
setupTempVarHelper("chat_message");
|
||||
setupTempVarHelper("chatter");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -1146,7 +1193,9 @@ MacroConditionTwitchEdit::MacroConditionTwitchEdit(
|
|||
_channel(new TwitchChannelSelection(this)),
|
||||
_pointsReward(new TwitchPointsRewardWidget(this)),
|
||||
_streamTitle(new VariableLineEdit(this)),
|
||||
_regex(new RegexConfigWidget(parent)),
|
||||
_regexTitle(new RegexConfigWidget(parent)),
|
||||
_chatMessage(new VariableTextEdit(this, 5, 1, 1)),
|
||||
_regexChat(new RegexConfigWidget(parent)),
|
||||
_category(new TwitchCategoryWidget(this))
|
||||
{
|
||||
_streamTitle->setSizePolicy(QSizePolicy::MinimumExpanding,
|
||||
|
|
@ -1170,8 +1219,12 @@ MacroConditionTwitchEdit::MacroConditionTwitchEdit(
|
|||
SLOT(PointsRewardChanged(const TwitchPointsReward &)));
|
||||
QWidget::connect(_streamTitle, SIGNAL(editingFinished()), this,
|
||||
SLOT(StreamTitleChanged()));
|
||||
QWidget::connect(_regex, SIGNAL(RegexConfigChanged(RegexConfig)), this,
|
||||
SLOT(RegexChanged(RegexConfig)));
|
||||
QWidget::connect(_regexTitle, SIGNAL(RegexConfigChanged(RegexConfig)),
|
||||
this, SLOT(RegexTitleChanged(RegexConfig)));
|
||||
QWidget::connect(_chatMessage, SIGNAL(textChanged()), this,
|
||||
SLOT(ChatMessageChanged()));
|
||||
QWidget::connect(_regexChat, SIGNAL(RegexConfigChanged(RegexConfig)),
|
||||
this, SLOT(RegexChatChanged(RegexConfig)));
|
||||
QWidget::connect(_category,
|
||||
SIGNAL(CategoreyChanged(const TwitchCategory &)), this,
|
||||
SLOT(CategoreyChanged(const TwitchCategory &)));
|
||||
|
|
@ -1184,7 +1237,7 @@ MacroConditionTwitchEdit::MacroConditionTwitchEdit(
|
|||
{"{{channel}}", _channel},
|
||||
{"{{pointsReward}}", _pointsReward},
|
||||
{"{{streamTitle}}", _streamTitle},
|
||||
{"{{regex}}", _regex},
|
||||
{"{{regex}}", _regexTitle},
|
||||
{"{{category}}", _category}});
|
||||
_layout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
|
|
@ -1196,6 +1249,10 @@ MacroConditionTwitchEdit::MacroConditionTwitchEdit(
|
|||
|
||||
auto mainLayout = new QVBoxLayout();
|
||||
mainLayout->addLayout(_layout);
|
||||
auto chatLayout = new QHBoxLayout();
|
||||
chatLayout->addWidget(_chatMessage);
|
||||
chatLayout->addWidget(_regexChat);
|
||||
mainLayout->addLayout(chatLayout);
|
||||
mainLayout->addLayout(accountLayout);
|
||||
mainLayout->addWidget(_tokenPermissionWarning);
|
||||
setLayout(mainLayout);
|
||||
|
|
@ -1238,6 +1295,7 @@ void MacroConditionTwitchEdit::TwitchTokenChanged(const QString &token)
|
|||
_category->SetToken(_entryData->_token);
|
||||
_channel->SetToken(_entryData->_token);
|
||||
_pointsReward->SetToken(_entryData->_token);
|
||||
_entryData->ResetChatConnection();
|
||||
|
||||
SetupWidgetVisibility();
|
||||
emit(HeaderInfoChanged(token));
|
||||
|
|
@ -1260,6 +1318,7 @@ void MacroConditionTwitchEdit::ChannelChanged(const TwitchChannel &channel)
|
|||
auto lock = LockContext();
|
||||
_entryData->SetChannel(channel);
|
||||
_pointsReward->SetChannel(channel);
|
||||
_entryData->ResetChatConnection();
|
||||
}
|
||||
|
||||
void MacroConditionTwitchEdit::PointsRewardChanged(
|
||||
|
|
@ -1283,14 +1342,37 @@ void MacroConditionTwitchEdit::StreamTitleChanged()
|
|||
_entryData->_streamTitle = _streamTitle->text().toStdString();
|
||||
}
|
||||
|
||||
void MacroConditionTwitchEdit::RegexChanged(RegexConfig conf)
|
||||
void MacroConditionTwitchEdit::ChatMessageChanged()
|
||||
{
|
||||
if (_loading || !_entryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto lock = LockContext();
|
||||
_entryData->_regex = conf;
|
||||
_entryData->_chatMessage = _chatMessage->toPlainText().toStdString();
|
||||
}
|
||||
|
||||
void MacroConditionTwitchEdit::RegexTitleChanged(RegexConfig conf)
|
||||
{
|
||||
if (_loading || !_entryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto lock = LockContext();
|
||||
_entryData->_regexTitle = conf;
|
||||
|
||||
adjustSize();
|
||||
updateGeometry();
|
||||
}
|
||||
|
||||
void MacroConditionTwitchEdit::RegexChatChanged(RegexConfig conf)
|
||||
{
|
||||
if (_loading || !_entryData) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto lock = LockContext();
|
||||
_entryData->_regexChat = conf;
|
||||
|
||||
adjustSize();
|
||||
updateGeometry();
|
||||
|
|
@ -1321,8 +1403,14 @@ void MacroConditionTwitchEdit::SetupWidgetVisibility()
|
|||
POINTS_REWARD_REDEMPTION_UPDATE_EVENT);
|
||||
_streamTitle->setVisible(
|
||||
condition == MacroConditionTwitch::Condition::TITLE_POLLING);
|
||||
_regex->setVisible(condition ==
|
||||
MacroConditionTwitch::Condition::TITLE_POLLING);
|
||||
_regexTitle->setVisible(condition ==
|
||||
MacroConditionTwitch::Condition::TITLE_POLLING);
|
||||
_chatMessage->setVisible(
|
||||
condition ==
|
||||
MacroConditionTwitch::Condition::CHAT_MESSAGE_RECEIVED);
|
||||
_regexChat->setVisible(
|
||||
condition ==
|
||||
MacroConditionTwitch::Condition::CHAT_MESSAGE_RECEIVED);
|
||||
_category->setVisible(
|
||||
condition == MacroConditionTwitch::Condition::CATEGORY_POLLING);
|
||||
|
||||
|
|
@ -1354,7 +1442,9 @@ void MacroConditionTwitchEdit::UpdateEntryData()
|
|||
_pointsReward->SetChannel(_entryData->_channel);
|
||||
_pointsReward->SetPointsReward(_entryData->_pointsReward);
|
||||
_streamTitle->setText(_entryData->_streamTitle);
|
||||
_regex->SetRegexConfig(_entryData->_regex);
|
||||
_regexTitle->SetRegexConfig(_entryData->_regexTitle);
|
||||
_chatMessage->setPlainText(_entryData->_chatMessage);
|
||||
_regexChat->SetRegexConfig(_entryData->_regexChat);
|
||||
_category->SetToken(_entryData->_token);
|
||||
_category->SetCategory(_entryData->_category);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
#include "category-selection.hpp"
|
||||
#include "channel-selection.hpp"
|
||||
#include "points-reward-selection.hpp"
|
||||
#include "chat-connection.hpp"
|
||||
|
||||
#include <variable-line-edit.hpp>
|
||||
#include <variable-text-edit.hpp>
|
||||
#include <regex-config.hpp>
|
||||
|
||||
namespace advss {
|
||||
|
|
@ -71,6 +73,9 @@ public:
|
|||
STREAM_ONLINE_PREMIERE_EVENT = 4,
|
||||
STREAM_ONLINE_RERUN_EVENT = 5,
|
||||
|
||||
// Chat
|
||||
CHAT_MESSAGE_RECEIVED = 500000,
|
||||
|
||||
// Polling
|
||||
LIVE_POLLING = 1000000,
|
||||
TITLE_POLLING = 1000100,
|
||||
|
|
@ -83,6 +88,7 @@ public:
|
|||
TwitchChannel GetChannel() const { return _channel; }
|
||||
void SetPointsReward(const TwitchPointsReward &pointsReward);
|
||||
TwitchPointsReward GetPointsReward() const { return _pointsReward; }
|
||||
void ResetChatConnection();
|
||||
|
||||
bool CheckCondition();
|
||||
bool Save(obs_data_t *obj) const;
|
||||
|
|
@ -94,12 +100,15 @@ public:
|
|||
TwitchPointsReward _pointsReward;
|
||||
StringVariable _streamTitle = obs_module_text(
|
||||
"AdvSceneSwitcher.condition.twitch.title.title");
|
||||
RegexConfig _regex = RegexConfig::PartialMatchRegexConfig();
|
||||
RegexConfig _regexTitle = RegexConfig::PartialMatchRegexConfig();
|
||||
StringVariable _chatMessage;
|
||||
RegexConfig _regexChat = RegexConfig::PartialMatchRegexConfig();
|
||||
TwitchCategory _category;
|
||||
|
||||
private:
|
||||
bool CheckChannelGenericEvents(TwitchToken &token);
|
||||
bool CheckChannelLiveEvents(TwitchToken &token);
|
||||
bool CheckChatMessages(TwitchToken &token);
|
||||
|
||||
void SetupEventSubscriptions();
|
||||
void ResetSubscription();
|
||||
|
|
@ -109,13 +118,18 @@ private:
|
|||
const char *version, bool includeModeratorId = false,
|
||||
const char *mainUserIdFieldName = "broadcaster_user_id",
|
||||
obs_data_t *extraConditions = nullptr);
|
||||
|
||||
void SetupTempVars();
|
||||
void SetTempVarValues(const ChannelLiveInfo &);
|
||||
void SetTempVarValues(const ChannelInfo &);
|
||||
|
||||
Condition _condition = Condition::LIVE_POLLING;
|
||||
|
||||
std::future<std::string> _subscriptionIDFuture;
|
||||
std::string _subscriptionID;
|
||||
|
||||
std::shared_ptr<TwitchChatConnection> _chatConnection;
|
||||
|
||||
static bool _registered;
|
||||
static const std::string id;
|
||||
};
|
||||
|
|
@ -143,7 +157,9 @@ private slots:
|
|||
void ChannelChanged(const TwitchChannel &);
|
||||
void PointsRewardChanged(const TwitchPointsReward &);
|
||||
void StreamTitleChanged();
|
||||
void RegexChanged(RegexConfig);
|
||||
void ChatMessageChanged();
|
||||
void RegexTitleChanged(RegexConfig);
|
||||
void RegexChatChanged(RegexConfig);
|
||||
void CategoreyChanged(const TwitchCategory &);
|
||||
|
||||
signals:
|
||||
|
|
@ -156,8 +172,6 @@ protected:
|
|||
private:
|
||||
void SetupWidgetVisibility();
|
||||
|
||||
bool _loading = true;
|
||||
|
||||
QHBoxLayout *_layout;
|
||||
FilterComboBox *_conditions;
|
||||
TwitchConnectionSelection *_tokens;
|
||||
|
|
@ -166,8 +180,12 @@ private:
|
|||
TwitchChannelSelection *_channel;
|
||||
TwitchPointsRewardWidget *_pointsReward;
|
||||
VariableLineEdit *_streamTitle;
|
||||
RegexConfigWidget *_regex;
|
||||
RegexConfigWidget *_regexTitle;
|
||||
VariableTextEdit *_chatMessage;
|
||||
RegexConfigWidget *_regexChat;
|
||||
TwitchCategoryWidget *_category;
|
||||
|
||||
bool _loading = true;
|
||||
};
|
||||
|
||||
} // namespace advss
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ const std::unordered_map<std::string, std::string> TokenOption::_apiIdToLocale{
|
|||
{"channel:moderate", "AdvSceneSwitcher.twitchToken.channel.moderate"},
|
||||
{"moderation:read",
|
||||
"AdvSceneSwitcher.twitchToken.channel.moderation.read"},
|
||||
{"chat:read", "AdvSceneSwitcher.twitchToken.chat.read"},
|
||||
{"chat:edit", "AdvSceneSwitcher.twitchToken.chat.edit"},
|
||||
};
|
||||
|
||||
static void saveConnections(obs_data_t *obj);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user