diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index f13ff81c..5239f049 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -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" diff --git a/src/macro-external/twitch/CMakeLists.txt b/src/macro-external/twitch/CMakeLists.txt index ded8df46..b64a919b 100644 --- a/src/macro-external/twitch/CMakeLists.txt +++ b/src/macro-external/twitch/CMakeLists.txt @@ -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 diff --git a/src/macro-external/twitch/chat-connection.cpp b/src/macro-external/twitch/chat-connection.cpp new file mode 100644 index 00000000..ae58c2e5 --- /dev/null +++ b/src/macro-external/twitch/chat-connection.cpp @@ -0,0 +1,716 @@ +#include "chat-connection.hpp" +#include "token.hpp" +#include "twitch-helpers.hpp" + +#include +#include + +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 tagsToIgnore = { + "client-nonce", "flags"}; + + std::vector 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 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 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> 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 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 parseMessages(const std::string &messagesStr) +{ + static constexpr std::string_view delimiter = "\r\n"; + std::vector messages; + + auto isWhitespace = [](const std::string &str) -> bool { + for (char c : str) { + if (!std::isspace(static_cast(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::_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::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 lock(_messageMtx); + _messages.clear(); +} + +std::mutex TwitchChatConnection::_instancesMtx; +std::vector TwitchChatConnection::_instances; + +void TwitchChatConnection::RegisterInstance() +{ + std::lock_guard lock(_instancesMtx); + _instances.emplace_back(this); +} + +void TwitchChatConnection::UnregisterInstance() +{ + std::lock_guard lock(_instancesMtx); + auto it = std::remove(_instances.begin(), _instances.end(), this); + _instances.erase(it, _instances.end()); +} + +std::shared_ptr +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( + new TwitchChatConnection(token_, channel)); + _chatMap[key] = connection; + return connection; +} + +void TwitchChatConnection::ClearAllMessages() +{ + std::lock_guard lock(_instancesMtx); + for (const auto &TwitchChatConnection : _instances) { + TwitchChatConnection->ClearMessages(); + } +} + +void TwitchChatConnection::ConnectThread() +{ + while (!_disconnect) { + std::unique_lock 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 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 lock(_connectMtx); + _disconnect = true; + websocketpp::lib::error_code ec; + _client.close(_connection, websocketpp::close::status::normal, + "Twitch chat connection stopping", ec); + { + std::unique_lock 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(c))); + } + + return result; +} + +std::vector TwitchChatConnection::Messages() +{ + if (!_connected) { + Connect(); + return {}; + } + + if (_joinedChannelName != + "#" + toLowerCase(std::string(_channel.GetName()))) { + // TODO: disconnect previous channel? + JoinChannel(_channel.GetName()); + return {}; + } + + std::lock_guard lock(_messageMtx); + return _messages; +} + +std::vector TwitchChatConnection::Whispers() +{ + if (!_connected) { + Connect(); + return {}; + } + + if (_joinedChannelName != + "#" + toLowerCase(std::string(_channel.GetName()))) { + // TODO: disconnect previous channel? + JoinChannel(_channel.GetName()); + return {}; + } + + std::lock_guard 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 lock(_messageMtx); + _joinedChannelName = std::get(message.command.parameters); + vblog(LOG_INFO, "Twitch chat join was successful!"); +} + +void TwitchChatConnection::HandleNewMessage(const IRCMessage &message) +{ + std::lock_guard 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 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(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 diff --git a/src/macro-external/twitch/chat-connection.hpp b/src/macro-external/twitch/chat-connection.hpp new file mode 100644 index 00000000..e3e6de30 --- /dev/null +++ b/src/macro-external/twitch/chat-connection.hpp @@ -0,0 +1,114 @@ +#pragma once +#include "channel-selection.hpp" +#include "token.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace advss { + +using websocketpp::connection_hdl; + +struct ParsedTags { + using BadgeMap = std::unordered_map; + using EmoteMap = std::unordered_map>>; + using EmoteSet = std::vector; + std::unordered_map> + tagMap; +}; + +struct IRCMessage { + ParsedTags tags; + struct { + std::string nick; + std::string host; + } source; + struct { + std::string command; + std::variant> + parameters; + } command; + std::string message; +}; + +class TwitchChatConnection : public QObject { +public: + ~TwitchChatConnection(); + + static std::shared_ptr + GetChatConnection(const TwitchToken &token, + const TwitchChannel &channel); + std::vector Messages(); + std::vector 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:: + 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> + _chatMap; + + TwitchToken _token; + TwitchChannel _channel; + std::string _joinedChannelName; + + websocketpp::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 _messages; + std::vector _whispers; + static std::mutex _instancesMtx; + static std::vector _instances; + static bool _setupDone; +}; + +} // namespace advss diff --git a/src/macro-external/twitch/macro-action-twitch.cpp b/src/macro-external/twitch/macro-action-twitch.cpp index 7248388c..5b739c39 100644 --- a/src/macro-external/twitch/macro-action-twitch.cpp +++ b/src/macro-external/twitch/macro-action-twitch.cpp @@ -37,6 +37,8 @@ const static std::map 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 @@ -221,6 +223,18 @@ void MacroActionTwitch::StartRaid(const std::shared_ptr &token) } } +void MacroActionTwitch::SendChatMessage( + const std::shared_ptr &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(_announcementColor)); _channel.Save(obj); + _chatMessage.Save(obj, "chatMessage"); return true; } @@ -310,6 +329,8 @@ bool MacroActionTwitch::Load(obs_data_t *obj) _announcementColor = static_cast( 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( _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 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(_entryData->_announcementColor)); _channel->SetToken(_entryData->_token); _channel->SetChannel(_entryData->_channel); + _chatMessage->setPlainText(_entryData->_chatMessage); SetupWidgetVisibility(); } diff --git a/src/macro-external/twitch/macro-action-twitch.hpp b/src/macro-external/twitch/macro-action-twitch.hpp index 6f151c3d..7459ab7a 100644 --- a/src/macro-external/twitch/macro-action-twitch.hpp +++ b/src/macro-external/twitch/macro-action-twitch.hpp @@ -3,6 +3,7 @@ #include "token.hpp" #include "category-selection.hpp" #include "channel-selection.hpp" +#include "chat-connection.hpp" #include #include @@ -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 _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 &) const; @@ -70,6 +74,9 @@ private: void SetChatEmoteOnlyMode(const std::shared_ptr &, bool enable) const; void StartRaid(const std::shared_ptr &); + void SendChatMessage(const std::shared_ptr &); + + std::shared_ptr _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 diff --git a/src/macro-external/twitch/macro-condition-twitch.cpp b/src/macro-external/twitch/macro-condition-twitch.cpp index 68c7e145..bcc5e6d3 100644 --- a/src/macro-external/twitch/macro-condition-twitch.cpp +++ b/src/macro-external/twitch/macro-condition-twitch.cpp @@ -118,6 +118,8 @@ const static std::map 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 eventIdentifiers = { @@ -240,6 +242,11 @@ void MacroConditionTwitch::SetPointsReward( ResetSubscription(); } +void MacroConditionTwitch::ResetChatConnection() +{ + _chatConnection.reset(); +} + static void setTempVarsHelper(const std::string &jsonStr, std::function 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(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); diff --git a/src/macro-external/twitch/macro-condition-twitch.hpp b/src/macro-external/twitch/macro-condition-twitch.hpp index 8f7e042c..884c5524 100644 --- a/src/macro-external/twitch/macro-condition-twitch.hpp +++ b/src/macro-external/twitch/macro-condition-twitch.hpp @@ -4,8 +4,10 @@ #include "category-selection.hpp" #include "channel-selection.hpp" #include "points-reward-selection.hpp" +#include "chat-connection.hpp" #include +#include #include 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 _subscriptionIDFuture; std::string _subscriptionID; + + std::shared_ptr _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 diff --git a/src/macro-external/twitch/token.cpp b/src/macro-external/twitch/token.cpp index 97883914..5cc8f5a8 100644 --- a/src/macro-external/twitch/token.cpp +++ b/src/macro-external/twitch/token.cpp @@ -55,6 +55,8 @@ const std::unordered_map 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);