diff --git a/CHANGELOG.md b/CHANGELOG.md index bea8f974..8918ffdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - Minor: Added a menu action to sort tabs alphabetically. (#6551) - Minor: Fixed "edit hotkey" dialog opening like a normal window. (#6540) - Minor: Added a setting to show the stream title in live messages. (#6572) +- Minor: Added broadcaster-only `/poll` command to start a poll. (#6583) +- Minor: Added broadcaster-only `/prediction` command to start a prediction. (#6583) - Bugfix: Expose the "Extra extension IDs" setting on non-Windows systems too. (#6509) - Bugfix: Fixed some commands and filters not working as expected in seach popups. (#6539) - Bugfix: Fixed settings occasionally not opening when clicking on "Manage Accounts" in the account switcher. (#6543) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index d042743b..34280c9e 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -428,6 +429,22 @@ public: FailureCallback failureCallback), (override)); + // create poll + MOCK_METHOD(void, createPoll, + (QString broadcasterID, QString title, QStringList choices, + std::chrono::seconds duration, int pointsPerVote, + ResultCallback<> successCallback, + FailureCallback failureCallback), + (override)); + + // create prediction + MOCK_METHOD(void, createPrediction, + (QString broadcasterID, QString title, QStringList outcomes, + std::chrono::seconds duration, + ResultCallback<> successCallback, + FailureCallback failureCallback), + (override)); + MOCK_METHOD(void, createEventSubSubscription, (const eventsub::SubscriptionRequest &request, const QString &sessionID, diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a477a597..56f442fb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -99,6 +99,10 @@ set(SOURCE_FILES controllers/commands/builtin/twitch/GetModerators.hpp controllers/commands/builtin/twitch/GetVIPs.cpp controllers/commands/builtin/twitch/GetVIPs.hpp + controllers/commands/builtin/twitch/Poll.cpp + controllers/commands/builtin/twitch/Poll.hpp + controllers/commands/builtin/twitch/Prediction.cpp + controllers/commands/builtin/twitch/Prediction.hpp controllers/commands/builtin/twitch/Raid.cpp controllers/commands/builtin/twitch/Raid.hpp controllers/commands/builtin/twitch/RemoveModerator.cpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index eecc54fa..e2f5aecd 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -15,6 +15,8 @@ #include "controllers/commands/builtin/twitch/DeleteMessages.hpp" #include "controllers/commands/builtin/twitch/GetModerators.hpp" #include "controllers/commands/builtin/twitch/GetVIPs.hpp" +#include "controllers/commands/builtin/twitch/Poll.hpp" +#include "controllers/commands/builtin/twitch/Prediction.hpp" #include "controllers/commands/builtin/twitch/Raid.hpp" #include "controllers/commands/builtin/twitch/RemoveModerator.hpp" #include "controllers/commands/builtin/twitch/RemoveVIP.hpp" @@ -481,6 +483,9 @@ CommandController::CommandController(const Paths &paths) this->registerCommand("/shoutout", &commands::sendShoutout); + this->registerCommand("/poll", &commands::createPoll); + this->registerCommand("/prediction", &commands::createPrediction); + this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); this->registerCommand("/c2-theme-autoreload", &commands::toggleThemeReload); } diff --git a/src/controllers/commands/builtin/twitch/Poll.cpp b/src/controllers/commands/builtin/twitch/Poll.cpp new file mode 100644 index 00000000..8913299e --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Poll.cpp @@ -0,0 +1,70 @@ +#include "controllers/commands/builtin/twitch/Poll.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/common/ChannelAction.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace { + +using namespace chatterino; + +constexpr auto MIN_POLL_DURATION = std::chrono::seconds(10); +constexpr auto MAX_POLL_DURATION = std::chrono::seconds(1800); + +} // namespace + +namespace chatterino::commands { + +QString createPoll(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/poll"); + const auto usage = QStringLiteral( + R"(Usage: "/poll --title "" --duration <duration>[time unit] --choice "<choice1>" --choice "<choice2>" [options...]" - Creates a poll for users to vote among the defined options. Title may not exceed 60 characters. There must be between two and five poll choices. Duration must be a positive integer; time unit (optional, default=s) must be one of s, m; maximum duration is 30 minutes. Options: --points <points> to allow spending the specified channel points for each additional vote.)"); + const auto action = parseUserParticipationAction( + ctx, command, usage, MIN_POLL_DURATION, MAX_POLL_DURATION); + + if (!action.has_value()) + { + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(action.error()); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << action.error(); + } + return ""; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage( + "You must be logged in to create a poll!"); + return ""; + } + + const auto &poll = action.value(); + getHelix()->createPoll( + poll.broadcasterID, poll.title, poll.choices, poll.duration, + poll.pointsPerVote, + [channel = ctx.channel, poll] { + channel->addSystemMessage( + QString("Created poll: '%1'").arg(poll.title)); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to create poll - " + error); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Poll.hpp b/src/controllers/commands/builtin/twitch/Poll.hpp new file mode 100644 index 00000000..e76d8ecd --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Poll.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /poll +QString createPoll(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Prediction.cpp b/src/controllers/commands/builtin/twitch/Prediction.cpp new file mode 100644 index 00000000..3662f1e9 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Prediction.cpp @@ -0,0 +1,69 @@ +#include "controllers/commands/builtin/twitch/Prediction.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/common/ChannelAction.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include <chrono> + +namespace { + +using namespace chatterino; + +constexpr auto MIN_PREDICT_DURATION = std::chrono::seconds(30); +constexpr auto MAX_PREDICT_DURATION = std::chrono::seconds(1800); + +} // namespace + +namespace chatterino::commands { + +QString createPrediction(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/prediction"); + const auto usage = QStringLiteral( + R"(Usage: "/prediction --title "<title>" --choice "<choice1>" --choice "<choice2>" --duration <duration>[time unit]" - Creates a prediction for users to guess among the defined options. Title may not exceed 45 characters. There must be between two and ten choices. Duration must be a positive integer; time unit (optional, default=s) must be one of s, m; maximum duration is 30 minutes.)"); + const auto action = parseUserParticipationAction( + ctx, command, usage, MIN_PREDICT_DURATION, MAX_PREDICT_DURATION); + + if (!action.has_value()) + { + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(action.error()); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << action.error(); + } + return ""; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage( + "You must be logged in to create a prediction!"); + return ""; + } + + const auto &data = action.value(); + getHelix()->createPrediction( + data.broadcasterID, data.title, data.choices, data.duration, + [channel = ctx.channel, data] { + channel->addSystemMessage( + QString("Created prediction: '%1'").arg(data.title)); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to create prediction - " + error); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Prediction.hpp b/src/controllers/commands/builtin/twitch/Prediction.hpp new file mode 100644 index 00000000..b9e2fa77 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Prediction.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /prediction +QString createPrediction(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/common/ChannelAction.cpp b/src/controllers/commands/common/ChannelAction.cpp index 48738592..1cdfb0e6 100644 --- a/src/controllers/commands/common/ChannelAction.cpp +++ b/src/controllers/commands/common/ChannelAction.cpp @@ -7,6 +7,7 @@ #include "util/Twitch.hpp" #include <QCommandLineParser> +#include <QProcess> #include <QStringBuilder> #include <algorithm> @@ -181,4 +182,83 @@ nonstd::expected<std::vector<PerformChannelAction>, QString> parseChannelAction( return actions; } +ExpectedStr<StartUserParticipationAction> parseUserParticipationAction( + const CommandContext &ctx, const QString &command, const QString &usage, + const std::chrono::seconds minDuration, + const std::chrono::seconds maxDuration) +{ + if (ctx.twitchChannel == nullptr) + { + // This action must be performed with a twitch channel as a context + return makeUnexpected("The " % command % + " command only works in Twitch channels"); + } + + // Define arguments + QCommandLineParser parser; + parser.setSingleDashWordOptionMode(QCommandLineParser::ParseAsLongOptions); + parser.setOptionsAfterPositionalArgumentsMode( + QCommandLineParser::ParseAsOptions); + QCommandLineOption titleOption({"t", "title"}, + "The title of the " % command, "title"); + QCommandLineOption durationOption( + {"d", "duration"}, "The duration of the " % command, "duration"); + QCommandLineOption pointsOption({"p", "points"}, + "The channel points per vote", "points"); + QCommandLineOption choiceOption( + {"c", "choice"}, "A viewer-selectable choice for the " % command, + "choice"); + parser.addOptions({ + titleOption, + durationOption, + choiceOption, + pointsOption, + }); + const auto joined = ctx.words.join(" "); + parser.parse(QProcess::splitCommand(joined)); + + // Input validation + if (!parser.isSet(titleOption)) + { + return makeUnexpected("Missing title - " % usage); + } + + if (!parser.isSet(durationOption)) + { + return makeUnexpected("Missing duration - " % usage); + } + const auto dur = parseDurationToSeconds(parser.value(durationOption)); + if (dur <= 0) + { + return makeUnexpected("Invalid duration - " % usage); + } + const auto duration = + std::clamp(std::chrono::seconds(dur), minDuration, maxDuration); + + if (!parser.isSet(choiceOption)) + { + return makeUnexpected("Missing choices - " % usage); + } + + // Build action + StartUserParticipationAction action{ + .broadcasterID = ctx.twitchChannel->roomId(), + .title = parser.value(titleOption), + .choices = parser.values(choiceOption), + .duration = duration, + }; + + if (parser.isSet(pointsOption)) + { + bool validPoints = true; + action.pointsPerVote = parser.value(pointsOption).toInt(&validPoints); + if (!validPoints) + { + return makeUnexpected("Invalid points - " % usage); + } + } + + return action; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/common/ChannelAction.hpp b/src/controllers/commands/common/ChannelAction.hpp index fd80eeb7..10e908ed 100644 --- a/src/controllers/commands/common/ChannelAction.hpp +++ b/src/controllers/commands/common/ChannelAction.hpp @@ -1,8 +1,12 @@ #pragma once +#include "util/Expected.hpp" + #include <nonstd/expected.hpp> #include <QString> +#include <QStringList> +#include <chrono> #include <ostream> #include <tuple> #include <vector> @@ -47,6 +51,14 @@ struct PerformChannelAction { } }; +struct StartUserParticipationAction { + QString broadcasterID; + QString title; + QStringList choices; + std::chrono::seconds duration; + int pointsPerVote = 0; +}; + std::ostream &operator<<(std::ostream &os, const IncompleteHelixUser &u); // gtest printer // NOLINTNEXTLINE(readability-identifier-naming) @@ -56,4 +68,8 @@ nonstd::expected<std::vector<PerformChannelAction>, QString> parseChannelAction( const CommandContext &ctx, const QString &command, const QString &usage, bool withDuration, bool withReason); +ExpectedStr<StartUserParticipationAction> parseUserParticipationAction( + const CommandContext &ctx, const QString &command, const QString &usage, + std::chrono::seconds minDuration, std::chrono::seconds maxDuration); + } // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 72ba887f..4bc5a506 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -3218,6 +3218,117 @@ void Helix::getFollowedChannel( .execute(); } +void Helix::createPoll(QString broadcasterID, QString title, + QStringList choices, const std::chrono::seconds duration, + const int pointsPerVote, + ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) +{ + // Prepare request body + QJsonArray choiceArray; + for (auto choice : choices) + { + choiceArray.append(QJsonObject{{{"title", choice}}}); + } + + QJsonObject json{{{"broadcaster_id", broadcasterID}, + {"title", title}, + {"duration", static_cast<int>(duration.count())}, + {"choices", choiceArray}}}; + + if (pointsPerVote > 0) + { + json["channel_points_voting_enabled"] = true; + json["channel_points_per_vote"] = static_cast<qint64>(pointsPerVote); + } + + // Execute API call + this->makePost("polls", {}) + .json(json) + .onSuccess([successCallback](const NetworkResult &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for creating a poll was " + << result.formatError() << "but we expected it to be 200"; + } + + successCallback(); + }) + .onError([failureCallback](const NetworkResult &result) -> void { + if (!result.status()) + { + failureCallback(result.formatError()); + return; + } + + const auto obj = result.parseJson(); + const auto message = obj.value("message").toString(); + if (!message.isEmpty()) + { + failureCallback(message); + } + else + { + failureCallback(result.formatError()); + } + }) + .execute(); +} + +void Helix::createPrediction(const QString broadcasterID, const QString title, + QStringList outcomes, + const std::chrono::seconds duration, + ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) +{ + // Prepare request body + QJsonArray outcomeArray; + for (auto outcome : outcomes) + { + outcomeArray.append(QJsonObject{{{"title", outcome}}}); + } + + QJsonObject payload; + payload.insert("broadcaster_id", broadcasterID); + payload.insert("title", title); + payload.insert("prediction_window", static_cast<int>(duration.count())); + payload.insert("outcomes", outcomeArray); + + // Execute API call + this->makePost("predictions", {}) + .json(payload) + .onSuccess([successCallback](const NetworkResult &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for creating a prediction was " + << result.formatError() << "but we expected it to be 200"; + } + + successCallback(); + }) + .onError([failureCallback](const NetworkResult &result) -> void { + if (!result.status()) + { + failureCallback(result.formatError()); + return; + } + + const auto obj = result.parseJson(); + const auto message = obj.value("message").toString(); + if (!message.isEmpty()) + { + failureCallback(message); + } + else + { + failureCallback(result.formatError()); + } + }) + .execute(); +} + void Helix::createEventSubSubscription( const eventsub::SubscriptionRequest &request, const QString &sessionID, ResultCallback<HelixCreateEventSubSubscriptionResponse> successCallback, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 2b58fdb3..efbbf324 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -16,6 +16,7 @@ #include <QUrl> #include <QUrlQuery> +#include <chrono> #include <functional> #include <optional> #include <unordered_set> @@ -1211,6 +1212,19 @@ public: ResultCallback<std::optional<HelixFollowedChannel>> successCallback, FailureCallback<QString> failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference#create-poll + virtual void createPoll(QString broadcasterID, QString title, + QStringList choices, std::chrono::seconds duration, + int pointsPerVote, ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) = 0; + + /// https://dev.twitch.tv/docs/api/reference#create-prediction + virtual void createPrediction(QString broadcasterID, QString title, + QStringList choices, + std::chrono::seconds duration, + ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription virtual void createEventSubSubscription( const eventsub::SubscriptionRequest &request, const QString &sessionID, @@ -1566,6 +1580,18 @@ public: ResultCallback<std::optional<HelixFollowedChannel>> successCallback, FailureCallback<QString> failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference#create-poll + void createPoll(QString broadcasterID, QString title, QStringList choices, + std::chrono::seconds duration, int pointsPerVote, + ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) final; + + /// https://dev.twitch.tv/docs/api/reference#create-prediction + void createPrediction(QString broadcasterID, QString title, + QStringList outcomes, std::chrono::seconds duration, + ResultCallback<> successCallback, + FailureCallback<QString> failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription void createEventSubSubscription( const eventsub::SubscriptionRequest &request, const QString &sessionID,