feat: add poll and prediction commands for broadcasters (#6583)
Reviewed-by: nerix <nero.9@hotmail.de> Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
@@ -428,6 +429,22 @@ public:
|
||||
FailureCallback<QString> failureCallback),
|
||||
(override));
|
||||
|
||||
// create poll
|
||||
MOCK_METHOD(void, createPoll,
|
||||
(QString broadcasterID, QString title, QStringList choices,
|
||||
std::chrono::seconds duration, int pointsPerVote,
|
||||
ResultCallback<> successCallback,
|
||||
FailureCallback<QString> failureCallback),
|
||||
(override));
|
||||
|
||||
// create prediction
|
||||
MOCK_METHOD(void, createPrediction,
|
||||
(QString broadcasterID, QString title, QStringList outcomes,
|
||||
std::chrono::seconds duration,
|
||||
ResultCallback<> successCallback,
|
||||
FailureCallback<QString> failureCallback),
|
||||
(override));
|
||||
|
||||
MOCK_METHOD(void, createEventSubSubscription,
|
||||
(const eventsub::SubscriptionRequest &request,
|
||||
const QString &sessionID,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <chrono>
|
||||
|
||||
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 "<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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user