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
+2
View File
@@ -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)
+17
View File
@@ -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,
+4
View File
@@ -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
+111
View File
@@ -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,
+26
View File
@@ -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,