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:
WarmUpTill 2023-11-06 20:05:20 +01:00 committed by WarmUpTill
parent 50dc404689
commit 3805aaeeec
9 changed files with 1076 additions and 46 deletions

View File

@ -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"

View File

@ -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

View 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

View 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

View File

@ -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();
}

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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);