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:
iProdigy
2025-11-23 09:55:39 -06:00
committed by GitHub
parent b63739e792
commit 118e1781dd
12 changed files with 432 additions and 0 deletions
@@ -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