diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index c7d78e93..ace9b40d 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1359,6 +1359,8 @@ AdvSceneSwitcher.action.twitch.type.channel.info.language.set="Set stream langua AdvSceneSwitcher.action.twitch.type.raid.start="Start raid" AdvSceneSwitcher.action.twitch.type.raid.end="Cancel raid" AdvSceneSwitcher.action.twitch.type.shoutout.send="Send shoutout" +AdvSceneSwitcher.action.twitch.type.poll.start="Start poll" +AdvSceneSwitcher.action.twitch.type.poll.end="End poll" AdvSceneSwitcher.action.twitch.type.shieldMode.start="Enable shield mode" AdvSceneSwitcher.action.twitch.type.shieldMode.end="Disable shield mode" AdvSceneSwitcher.action.twitch.type.commercial.start="Start commercial" @@ -1403,6 +1405,10 @@ AdvSceneSwitcher.action.twitch.layout.user.ban.row1="Using account{{account}}{{a AdvSceneSwitcher.action.twitch.layout.user.ban.row2="on channel{{channel}}for{{userInfoQueryType}}{{userLogin}}{{userId}}reason{{banReason}}" AdvSceneSwitcher.action.twitch.layout.reward.getInfo.row1="Using account{{account}}{{actions}}for channel{{channel}}" AdvSceneSwitcher.action.twitch.layout.reward.getInfo.row2="{{pointsReward}}{{rewardVariable}}{{toggleRewardSelection}}" +AdvSceneSwitcher.action.twitch.layout.poll.start.row1="Using account{{account}}{{actions}}" +AdvSceneSwitcher.action.twitch.layout.poll.start.row2="on{{channel}}for{{duration}}" +AdvSceneSwitcher.action.twitch.layout.poll.end.row1="Using account{{account}}{{actions}}" +AdvSceneSwitcher.action.twitch.layout.poll.end.row2="on{{channel}}{{pollEndStatus}}" 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" @@ -1412,6 +1418,9 @@ AdvSceneSwitcher.action.twitch.announcement.blue="Blue" AdvSceneSwitcher.action.twitch.announcement.green="Green" AdvSceneSwitcher.action.twitch.announcement.orange="Orange" AdvSceneSwitcher.action.twitch.announcement.purple="Purple" +AdvSceneSwitcher.action.twitch.poll.choices="Enter one choice per line (2-10 choices, max 25 characters each)" +AdvSceneSwitcher.action.twitch.poll.end.terminated="Terminate (show results)" +AdvSceneSwitcher.action.twitch.poll.end.archived="Archive (hide results)" AdvSceneSwitcher.action.twitch.user.getInfo.queryType.id="User Id" AdvSceneSwitcher.action.twitch.user.getInfo.queryType.login="User login" AdvSceneSwitcher.action.twitch.tags.add="Add Channel Tag" diff --git a/plugins/twitch/macro-action-twitch.cpp b/plugins/twitch/macro-action-twitch.cpp index 96e6f1b7..a78192b8 100644 --- a/plugins/twitch/macro-action-twitch.cpp +++ b/plugins/twitch/macro-action-twitch.cpp @@ -26,6 +26,8 @@ void MacroActionTwitch::ResolveVariablesToFixedValues() _userLogin.ResolveVariables(); _userId.ResolveVariables(); _banReason.ResolveVariables(); + _pollTitle.ResolveVariables(); + _pollChoices.ResolveVariables(); _useVariableForRewardSelection = false; auto token = _token.lock(); if (token) { @@ -74,6 +76,10 @@ const static std::map actionTypes = { "AdvSceneSwitcher.action.twitch.type.raid.end"}, {MacroActionTwitch::Action::SHOUTOUT_SEND, "AdvSceneSwitcher.action.twitch.type.shoutout.send"}, + {MacroActionTwitch::Action::POLL_START, + "AdvSceneSwitcher.action.twitch.type.poll.start"}, + {MacroActionTwitch::Action::POLL_END, + "AdvSceneSwitcher.action.twitch.type.poll.end"}, {MacroActionTwitch::Action::SHIELD_MODE_START, "AdvSceneSwitcher.action.twitch.type.shieldMode.start"}, {MacroActionTwitch::Action::SHIELD_MODE_END, @@ -349,6 +355,131 @@ static void setChatSetting(const TwitchToken &token, const char *field, } } +static std::vector splitLines(const std::string &str) +{ + std::vector lines; + size_t start = 0; + while (start < str.size()) { + size_t end = str.find('\n', start); + if (end == std::string::npos) { + end = str.size(); + } + size_t lineEnd = end; + if (lineEnd > start && str[lineEnd - 1] == '\r') { + --lineEnd; + } + if (lineEnd > start) { + lines.push_back(str.substr(start, lineEnd - start)); + } + start = end + 1; + } + return lines; +} + +void MacroActionTwitch::StartPoll( + const std::shared_ptr &token) const +{ + const auto id = token->GetUserID(); + if (!id) { + vblog(LOG_INFO, "%s skip - invalid user id", __func__); + return; + } + + const auto pollTitle = std::string(_pollTitle); + if (pollTitle.empty()) { + blog(LOG_WARNING, "Poll title is empty!"); + return; + } + + auto choices = splitLines(std::string(_pollChoices)); + if (choices.size() < 2) { + blog(LOG_WARNING, "Poll requires at least 2 choices!"); + return; + } + if (choices.size() > 10) { + choices.resize(10); + } + + const auto duration = _duration.Seconds(); + if (duration < 15 || duration > 1800) { + blog(LOG_WARNING, + "Poll duration must be between 15 and 1800 seconds! (%d)", + (int)duration); + return; + } + + const auto channelId = _channel.GetUserID(*token); + const auto &broadcasterId = channelId.empty() ? *id : channelId; + + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_string(data, "broadcaster_id", broadcasterId.c_str()); + obs_data_set_string(data, "title", pollTitle.c_str()); + obs_data_set_int(data, "duration", duration); + + OBSDataArrayAutoRelease choicesArray = obs_data_array_create(); + for (const auto &choice : choices) { + OBSDataAutoRelease choiceData = obs_data_create(); + obs_data_set_string(choiceData, "title", choice.c_str()); + obs_data_array_push_back(choicesArray, choiceData); + } + obs_data_set_array(data, "choices", choicesArray); + + auto result = SendPostRequest(*token, "https://api.twitch.tv", + "/helix/polls", {}, data.Get()); + if (result.status != 200) { + blog(LOG_INFO, "Failed to start poll! (%d)", result.status); + } +} + +const static std::map + pollEndStatusesTwitch = { + {MacroActionTwitch::PollEndStatus::TERMINATED, "TERMINATED"}, + {MacroActionTwitch::PollEndStatus::ARCHIVED, "ARCHIVED"}, +}; + +void MacroActionTwitch::EndPoll(const std::shared_ptr &token) const +{ + const auto id = token->GetUserID(); + if (!id) { + vblog(LOG_INFO, "%s skip - invalid user id", __func__); + return; + } + + const auto channelId = _channel.GetUserID(*token); + const auto &broadcasterId = channelId.empty() ? *id : channelId; + + auto getResult = SendGetRequest( + *token, "https://api.twitch.tv", "/helix/polls", + {{"broadcaster_id", broadcasterId}, {"status", "ACTIVE"}}); + if (getResult.status != 200) { + blog(LOG_INFO, "Failed to get active poll! (%d)", + getResult.status); + return; + } + + OBSDataArrayAutoRelease array = + obs_data_get_array(getResult.data, "data"); + if (obs_data_array_count(array) == 0) { + blog(LOG_INFO, "No active poll found!"); + return; + } + + OBSDataAutoRelease pollData = obs_data_array_item(array, 0); + const auto pollId = obs_data_get_string(pollData, "id"); + + OBSDataAutoRelease patchData = obs_data_create(); + obs_data_set_string(patchData, "broadcaster_id", broadcasterId.c_str()); + obs_data_set_string(patchData, "id", pollId); + obs_data_set_string(patchData, "status", + pollEndStatusesTwitch.at(_pollEndStatus).c_str()); + + auto result = SendPatchRequest(*token, "https://api.twitch.tv", + "/helix/polls", {}, patchData.Get()); + if (result.status != 200) { + blog(LOG_INFO, "Failed to end poll! (%d)", result.status); + } +} + void MacroActionTwitch::StartRaid(const std::shared_ptr &token) { const auto id = token->GetUserID(); @@ -774,6 +905,12 @@ bool MacroActionTwitch::PerformAction() } break; } + case Action::POLL_START: + StartPoll(token); + break; + case Action::POLL_END: + EndPoll(token); + break; case Action::SHIELD_MODE_START: case Action::SHIELD_MODE_END: { const auto id = token->GetUserID(); @@ -1100,6 +1237,10 @@ bool MacroActionTwitch::Save(obs_data_t *obj) const _userLogin.Save(obj, "userLogin"); _userId.Save(obj, "userId"); _banReason.Save(obj, "banReason"); + _pollTitle.Save(obj, "pollTitle"); + _pollChoices.Save(obj, "pollChoices"); + obs_data_set_int(obj, "pollEndStatus", + static_cast(_pollEndStatus)); _pointsReward.Save(obj); obs_data_set_string(obj, "rewardVariable", GetWeakVariableName(_rewardVariable).c_str()); @@ -1138,6 +1279,10 @@ bool MacroActionTwitch::Load(obs_data_t *obj) _userLogin.Load(obj, "userLogin"); _userId.Load(obj, "userId"); _banReason.Load(obj, "banReason"); + _pollTitle.Load(obj, "pollTitle"); + _pollChoices.Load(obj, "pollChoices"); + _pollEndStatus = static_cast( + obs_data_get_int(obj, "pollEndStatus")); _pointsReward.Load(obj); _rewardVariable = GetWeakVariableByName( obs_data_get_string(obj, "rewardVariable")); @@ -1180,6 +1325,7 @@ bool MacroActionTwitch::ActionIsSupportedByToken() {Action::RAID_END, {{"channel:manage:raids"}}}, {Action::SHOUTOUT_SEND, {{"moderator:manage:shoutouts"}}}, + {Action::POLL_START, {{"channel:manage:polls"}}}, {Action::POLL_END, {{"channel:manage:polls"}}}, {Action::PREDICTION_END, {{"channel:manage:predictions"}}}, @@ -1361,7 +1507,10 @@ MacroActionTwitchEdit::MacroActionTwitchEdit( _layout2(new QHBoxLayout()), _pointsReward(new TwitchPointsRewardWidget(this, false)), _rewardVariable(new VariableSelection(this)), - _toggleRewardSelection(new QPushButton()) + _toggleRewardSelection(new QPushButton()), + _pollTitle(new VariableLineEdit(this)), + _pollChoices(new VariableTextEdit(this)), + _pollEndStatus(new QComboBox(this)) { _layout2->setContentsMargins(0, 0, 0, 0); _userModerationRow->setLayout(_layout2); @@ -1377,6 +1526,8 @@ MacroActionTwitchEdit::MacroActionTwitchEdit( mainLayout->addWidget(_userModerationRow); mainLayout->addWidget(_announcementMessage); mainLayout->addWidget(_chatMessage); + mainLayout->addWidget(_pollTitle); + mainLayout->addWidget(_pollChoices); mainLayout->addWidget(_tags); mainLayout->addWidget(_language); mainLayout->addWidget(_contentClassification); @@ -1546,9 +1697,24 @@ void MacroActionTwitchEdit::SetWidgetProperties() _announcementMessage->setMaxLength(500); auto durationSpinBox = _duration->SpinBox(); - durationSpinBox->setMaximum(180); + durationSpinBox->setMaximum(1800); durationSpinBox->setSuffix("s"); + _pollTitle->setSizePolicy(QSizePolicy::MinimumExpanding, + QSizePolicy::Preferred); + _pollTitle->setMaxLength(60); + _pollChoices->setToolTip( + obs_module_text("AdvSceneSwitcher.action.twitch.poll.choices")); + + _pollEndStatus->addItem( + obs_module_text( + "AdvSceneSwitcher.action.twitch.poll.end.terminated"), + static_cast(MacroActionTwitch::PollEndStatus::TERMINATED)); + _pollEndStatus->addItem( + obs_module_text( + "AdvSceneSwitcher.action.twitch.poll.end.archived"), + static_cast(MacroActionTwitch::PollEndStatus::ARCHIVED)); + populateActionSelection(_actions); populateAnnouncementColorSelection(_announcementColor); populateUserQueryInfoTypeSelection(_userInfoQueryType); @@ -1626,6 +1792,12 @@ void MacroActionTwitchEdit::SetWidgetSignalConnections() this, SLOT(UserIdChanged(const NumberVariable &))); QWidget::connect(_banReason, SIGNAL(editingFinished()), this, SLOT(BanReasonChanged())); + QWidget::connect(_pollTitle, SIGNAL(editingFinished()), this, + SLOT(PollTitleChanged())); + QWidget::connect(_pollChoices, SIGNAL(textChanged()), this, + SLOT(PollChoicesChanged())); + QWidget::connect(_pollEndStatus, SIGNAL(currentIndexChanged(int)), this, + SLOT(PollEndStatusChanged(int))); QWidget::connect( _pointsReward, SIGNAL(PointsRewardChanged(const TwitchPointsReward &)), this, @@ -1665,17 +1837,27 @@ void MacroActionTwitchEdit::SetWidgetVisibility() action == MacroActionTwitch::Action::SHOUTOUT_SEND || action == MacroActionTwitch::Action::SEND_CHAT_MESSAGE || action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO || + action == MacroActionTwitch::Action::POLL_START || + action == MacroActionTwitch::Action::POLL_END || isChannelModAction); _duration->setVisible( action == MacroActionTwitch::Action::COMMERCIAL_START || - action == MacroActionTwitch::Action::USER_BAN); + action == MacroActionTwitch::Action::USER_BAN || + action == MacroActionTwitch::Action::POLL_START); + _pollTitle->setVisible(action == MacroActionTwitch::Action::POLL_START); + _pollChoices->setVisible(action == + MacroActionTwitch::Action::POLL_START); + _pollEndStatus->setVisible(action == + MacroActionTwitch::Action::POLL_END); _banReason->setVisible(action == MacroActionTwitch::Action::USER_BAN); _userModerationRow->setVisible( isChannelModAction || action == MacroActionTwitch::Action::USER_GET_INFO || action == MacroActionTwitch::Action::USER_BLOCK || action == MacroActionTwitch::Action::USER_UNBLOCK || - action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO); + action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO || + action == MacroActionTwitch::Action::POLL_START || + action == MacroActionTwitch::Action::POLL_END); _markerDescription->setVisible( action == MacroActionTwitch::Action::MARKER_CREATE); _clipHasDelay->setVisible(action == @@ -1787,6 +1969,28 @@ void MacroActionTwitchEdit::BanReasonChanged() _entryData->_banReason = _banReason->text().toStdString(); } +void MacroActionTwitchEdit::PollTitleChanged() +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_pollTitle = _pollTitle->text().toStdString(); +} + +void MacroActionTwitchEdit::PollChoicesChanged() +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_pollChoices = _pollChoices->toPlainText().toStdString(); + adjustSize(); + updateGeometry(); +} + +void MacroActionTwitchEdit::PollEndStatusChanged(int idx) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_pollEndStatus = + static_cast( + _pollEndStatus->itemData(idx).toInt()); +} + void MacroActionTwitchEdit::PointsRewardChanged(const TwitchPointsReward &reward) { GUARD_LOADING_AND_LOCK(); @@ -1811,7 +2015,8 @@ void MacroActionTwitchEdit::SetWidgetLayout() _banReason, _pointsReward, _rewardVariable, - _toggleRewardSelection}; + _toggleRewardSelection, + _pollEndStatus}; for (auto widget : widgets) { _layout->removeWidget(widget); _layout2->removeWidget(widget); @@ -1836,7 +2041,8 @@ void MacroActionTwitchEdit::SetWidgetLayout() {"{{banReason}}", _banReason}, {"{{pointsReward}}", _pointsReward}, {"{{rewardVariable}}", _rewardVariable}, - {"{{toggleRewardSelection}}", _toggleRewardSelection}}; + {"{{toggleRewardSelection}}", _toggleRewardSelection}, + {"{{pollEndStatus}}", _pollEndStatus}}; const char *layoutText; const char *layout2Text = nullptr; @@ -1882,6 +2088,18 @@ void MacroActionTwitchEdit::SetWidgetLayout() layoutText = obs_module_text( "AdvSceneSwitcher.action.twitch.layout.channel.getInfo"); break; + case MacroActionTwitch::Action::POLL_START: + layoutText = obs_module_text( + "AdvSceneSwitcher.action.twitch.layout.poll.start.row1"); + layout2Text = obs_module_text( + "AdvSceneSwitcher.action.twitch.layout.poll.start.row2"); + break; + case MacroActionTwitch::Action::POLL_END: + layoutText = obs_module_text( + "AdvSceneSwitcher.action.twitch.layout.poll.end.row1"); + layout2Text = obs_module_text( + "AdvSceneSwitcher.action.twitch.layout.poll.end.row2"); + break; default: layoutText = obs_module_text( "AdvSceneSwitcher.action.twitch.layout.default"); @@ -1938,6 +2156,10 @@ void MacroActionTwitchEdit::UpdateEntryData() _userLogin->setText(_entryData->_userLogin); _userId->SetValue(_entryData->_userId); _banReason->setText(_entryData->_banReason); + _pollTitle->setText(_entryData->_pollTitle); + _pollChoices->setPlainText(_entryData->_pollChoices); + _pollEndStatus->setCurrentIndex(_pollEndStatus->findData( + static_cast(_entryData->_pollEndStatus))); _pointsReward->SetToken(_entryData->_token); _pointsReward->SetChannel(_entryData->_channel); _pointsReward->SetPointsReward(_entryData->_pointsReward); diff --git a/plugins/twitch/macro-action-twitch.hpp b/plugins/twitch/macro-action-twitch.hpp index aa17784f..be4e4085 100644 --- a/plugins/twitch/macro-action-twitch.hpp +++ b/plugins/twitch/macro-action-twitch.hpp @@ -44,7 +44,7 @@ public: SHOUTOUT_SEND = 200, // Poll - POLL_START = 300, // TODO + POLL_START = 300, POLL_END = 310, // Prediction @@ -147,6 +147,11 @@ public: CHANNEL_GET_INFO = 6100, }; + enum class PollEndStatus { + TERMINATED, + ARCHIVED, + }; + enum class AnnouncementColor { PRIMARY, BLUE, @@ -193,6 +198,9 @@ public: TwitchPointsReward _pointsReward; std::weak_ptr _rewardVariable; bool _useVariableForRewardSelection = false; + StringVariable _pollTitle = "Poll question"; + StringVariable _pollChoices = "Choice 1\nChoice 2"; + PollEndStatus _pollEndStatus = PollEndStatus::TERMINATED; private: void SetStreamTitle(const std::shared_ptr &) const; @@ -202,6 +210,8 @@ private: void StartCommercial(const std::shared_ptr &) const; void SendChatAnnouncement(const std::shared_ptr &) const; void StartRaid(const std::shared_ptr &); + void StartPoll(const std::shared_ptr &) const; + void EndPoll(const std::shared_ptr &) const; void SendChatMessage(const std::shared_ptr &); void GetUserInfo(const std::shared_ptr &); void GetRewardInfo(const std::shared_ptr &); @@ -261,6 +271,9 @@ private slots: void UserLoginChanged(); void UserIdChanged(const NumberVariable &); void BanReasonChanged(); + void PollTitleChanged(); + void PollChoicesChanged(); + void PollEndStatusChanged(int); void PointsRewardChanged(const TwitchPointsReward &); void RewardVariableChanged(const QString &); void ToggleRewardSelection(bool); @@ -305,6 +318,9 @@ private: // we use QDoubleSpinBox instead VariableDoubleSpinBox *_userId; VariableLineEdit *_banReason; + VariableLineEdit *_pollTitle; + VariableTextEdit *_pollChoices; + QComboBox *_pollEndStatus; TwitchPointsRewardWidget *_pointsReward; VariableSelection *_rewardVariable; QPushButton *_toggleRewardSelection;