From 40483efbf8e2094e3974128d1f538ced31734460 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sun, 7 Dec 2025 04:04:36 -0600 Subject: [PATCH] feat: add /lockprediction and /cancelprediction commands for broadcasters (#6612) Reviewed-by: pajlada --- .clang-tidy | 1 + CHANGELOG.md | 2 +- mocks/include/mocks/Helix.hpp | 16 ++ .../commands/CommandController.cpp | 2 + .../commands/builtin/twitch/Prediction.cpp | 152 ++++++++++++++++++ .../commands/builtin/twitch/Prediction.hpp | 6 + src/providers/twitch/api/Helix.cpp | 111 +++++++++++++ src/providers/twitch/api/Helix.hpp | 89 +++++++++- 8 files changed, 374 insertions(+), 5 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index d87009b9..9a2de7a0 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -13,6 +13,7 @@ Checks: "-*, bugprone-*, cert-*, clazy-*, + -clazy-qstring-allocations, cppcoreguidelines-*, -cppcoreguidelines-pro-type-cstyle-cast, -cppcoreguidelines-pro-type-vararg, diff --git a/CHANGELOG.md b/CHANGELOG.md index b8681c52..9be398f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Minor: Added a setting to show the stream title in live messages. (#6572) - Minor: Add categories to the emoji viewer. (#6598) - Minor: Added broadcaster-only `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605) -- Minor: Added broadcaster-only `/prediction` command to start a prediction. (#6583) +- Minor: Added broadcaster-only `/prediction`, `/cancelprediction`, and `/lockprediction` commands. (#6583, #6612) - 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 e4ef3f5e..e018ef43 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -459,6 +459,22 @@ public: FailureCallback failureCallback), (override)); + // get predictions + MOCK_METHOD(void, getPredictions, + (QString broadcasterID, QStringList ids, int first, + QString after, + ResultCallback successCallback, + FailureCallback failureCallback), + (override)); + + // end prediction + MOCK_METHOD(void, endPrediction, + (QString broadcasterID, QString id, bool refundPoints, + QString winningOutcomeID, + ResultCallback successCallback, + FailureCallback failureCallback), + (override)); + MOCK_METHOD(void, createEventSubSubscription, (const eventsub::SubscriptionRequest &request, const QString &sessionID, diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 866a02f5..bd60c152 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -488,6 +488,8 @@ CommandController::CommandController(const Paths &paths) this->registerCommand("/endpoll", &commands::endPoll); this->registerCommand("/prediction", &commands::createPrediction); + this->registerCommand("/lockprediction", &commands::lockPrediction); + this->registerCommand("/cancelprediction", &commands::cancelPrediction); this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); this->registerCommand("/c2-theme-autoreload", &commands::toggleThemeReload); diff --git a/src/controllers/commands/builtin/twitch/Prediction.cpp b/src/controllers/commands/builtin/twitch/Prediction.cpp index 3662f1e9..9ba68a1a 100644 --- a/src/controllers/commands/builtin/twitch/Prediction.cpp +++ b/src/controllers/commands/builtin/twitch/Prediction.cpp @@ -8,6 +8,7 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" #include @@ -66,4 +67,155 @@ QString createPrediction(const CommandContext &ctx) return ""; } +QString lockPrediction(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + const auto err = QStringLiteral( + "The /lockprediction command only works in Twitch channels"); + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(err); + } + else + { + qCWarning(chatterinoCommands) << "Invalid command context:" << err; + } + return ""; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage( + "You must be logged in to lock a prediction!"); + return ""; + } + + const auto roomId = ctx.twitchChannel->roomId(); + getHelix()->getPredictions( + roomId, {}, 1, {}, + [channel = ctx.channel, roomId](const auto &result) { + if (result.predictions.empty()) + { + channel->addSystemMessage("Failed to find any predictions"); + return; + } + + auto prediction = result.predictions.front(); + if (prediction.status == "LOCKED") + { + channel->addSystemMessage( + "The current prediction is already locked: " + + prediction.title); + return; + } + + if (prediction.status != "ACTIVE") + { + channel->addSystemMessage( + "Could not find an active prediction"); + return; + } + + getHelix()->endPrediction( + roomId, prediction.id, false, {}, + [channel](const HelixPrediction &data) { + int totalPoints = 0; + int numUsers = 0; + for (const auto &outcome : data.outcomes) + { + totalPoints += outcome.channelPoints; + numUsers += outcome.users; + } + + channel->addSystemMessage( + QString("Locked prediction with %1 points wagered by " + "%2 users: '%3'") + .arg(localizeNumbers(totalPoints), + localizeNumbers(numUsers), data.title)); + }, + [channel](const auto &error) { + channel->addSystemMessage("Failed to lock prediction - " + + error); + }); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to query predictions - " + error); + }); + + return ""; +} + +QString cancelPrediction(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + const auto err = QStringLiteral( + "The /cancelprediction command only works in Twitch channels"); + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(err); + } + else + { + qCWarning(chatterinoCommands) << "Invalid command context:" << err; + } + return ""; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage( + "You must be logged in to cancel a prediction!"); + return ""; + } + + const auto roomId = ctx.twitchChannel->roomId(); + getHelix()->getPredictions( + roomId, {}, 1, {}, + [channel = ctx.channel, roomId](const auto &result) { + if (result.predictions.empty()) + { + channel->addSystemMessage("Failed to find any predictions"); + return; + } + + auto prediction = result.predictions.front(); + if (prediction.status != "ACTIVE" && prediction.status != "LOCKED") + { + channel->addSystemMessage("Could not find an open prediction"); + return; + } + + getHelix()->endPrediction( + roomId, prediction.id, true, {}, + [channel](const HelixPrediction &data) { + int totalPoints = 0; + int numUsers = 0; + for (const auto &outcome : data.outcomes) + { + totalPoints += outcome.channelPoints; + numUsers += outcome.users; + } + + channel->addSystemMessage( + QString("Refunded %1 points to %2 users for " + "prediction: '%3'") + .arg(localizeNumbers(totalPoints), + localizeNumbers(numUsers), data.title)); + }, + [channel](const auto &error) { + channel->addSystemMessage("Failed to cancel prediction - " + + error); + }); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to query predictions - " + error); + }); + + return ""; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Prediction.hpp b/src/controllers/commands/builtin/twitch/Prediction.hpp index b9e2fa77..50095a4f 100644 --- a/src/controllers/commands/builtin/twitch/Prediction.hpp +++ b/src/controllers/commands/builtin/twitch/Prediction.hpp @@ -13,4 +13,10 @@ namespace chatterino::commands { /// /prediction QString createPrediction(const CommandContext &ctx); +/// /lockprediction +QString lockPrediction(const CommandContext &ctx); + +/// /cancelprediction +QString cancelPrediction(const CommandContext &ctx); + } // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index a85f38ab..0ce1b761 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -3426,6 +3426,117 @@ void Helix::createPrediction(const QString broadcasterID, const QString title, .execute(); } +void Helix::getPredictions(const QString broadcasterID, QStringList ids, + const int first, const QString after, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("first", QString::number(first)); + + if (!after.isEmpty()) + { + urlQuery.addQueryItem("after", after); + } + + for (const auto &id : ids) + { + urlQuery.addQueryItem("id", id); + } + + this->makeGet("predictions", urlQuery) + .onSuccess([successCallback](const auto &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting predictions was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + successCallback(HelixPredictions(response)); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + if (!message.isEmpty()) + { + failureCallback(message); + } + else + { + failureCallback(result.formatError()); + } + }) + .execute(); +} + +// End prediction can lock, cancel, or resolve an outstanding prediction. +void Helix::endPrediction(const QString broadcasterID, const QString id, + const bool refundPoints, + const QString winningOutcomeID, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject payload; + payload.insert("broadcaster_id", broadcasterID); + payload.insert("id", id); + if (refundPoints) + { + payload.insert("status", "CANCELED"); + } + else if (winningOutcomeID.isEmpty()) + { + payload.insert("status", "LOCKED"); + } + else + { + payload.insert("status", "RESOLVED"); + payload.insert("winning_outcome_id", winningOutcomeID); + } + + this->makePatch("predictions", {}) + .json(payload) + .onSuccess([successCallback](const NetworkResult &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for ending a prediction was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + const auto data = HelixPredictions(response); + successCallback(data.predictions.front()); + }) + .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 successCallback, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 93c3810d..be8949b3 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -488,8 +488,9 @@ struct HelixPoll { , title(jsonObject.value("title").toString()) , status(jsonObject.value("status").toString()) { - for (const auto &data = jsonObject.value("choices").toArray(); - const auto &c : data) + const auto &data = jsonObject.value("choices").toArray(); + this->choices.reserve(data.size()); + for (const auto &c : data) { HelixPollChoice choice(c.toObject()); this->choices.push_back(choice); @@ -504,8 +505,9 @@ struct HelixPolls { explicit HelixPolls(const QJsonObject &jsonObject) { - for (const auto &data = jsonObject.value("data").toArray(); - const auto &p : data) + const auto &data = jsonObject.value("data").toArray(); + this->polls.reserve(data.size()); + for (const auto &p : data) { HelixPoll poll(p.toObject()); this->polls.push_back(poll); @@ -513,6 +515,61 @@ struct HelixPolls { } }; +struct HelixPredictionOutcome { + QString id; + QString title; + int users; + int channelPoints; + + explicit HelixPredictionOutcome(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , title(jsonObject.value("title").toString()) + , users(jsonObject.value("users").toInt()) + , channelPoints(jsonObject.value("channel_points").toInt()) + { + } +}; + +struct HelixPrediction { + QString id; + QString title; + QString winningOutcomeID; + QString status; + std::vector outcomes; + + explicit HelixPrediction(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , title(jsonObject.value("title").toString()) + , winningOutcomeID(jsonObject.value("winning_outcome_id").toString()) + , status(jsonObject.value("status").toString()) + { + const auto &data = jsonObject.value("outcomes").toArray(); + this->outcomes.reserve(data.size()); + for (const auto &o : data) + { + HelixPredictionOutcome outcome(o.toObject()); + this->outcomes.push_back(outcome); + } + } +}; + +struct HelixPredictions { + std::vector predictions; + + HelixPredictions() = default; + + explicit HelixPredictions(const QJsonObject &jsonObject) + { + const auto &data = jsonObject.value("data").toArray(); + this->predictions.reserve(data.size()); + for (const auto &p : data) + { + HelixPrediction prediction(p.toObject()); + this->predictions.push_back(prediction); + } + } +}; + enum class HelixAnnouncementColor { Blue, Green, @@ -1286,6 +1343,18 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference#get-predictions + virtual void getPredictions( + QString broadcasterID, QStringList ids, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + + /// https://dev.twitch.tv/docs/api/reference#end-prediction + virtual void endPrediction(QString broadcasterID, QString id, + bool refundPoints, QString winningOutcomeID, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription virtual void createEventSubSubscription( const eventsub::SubscriptionRequest &request, const QString &sessionID, @@ -1663,6 +1732,18 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference#get-predictions + void getPredictions(QString broadcasterID, QStringList ids, int first, + QString after, + ResultCallback successCallback, + FailureCallback failureCallback) final; + + /// https://dev.twitch.tv/docs/api/reference#end-prediction + void endPrediction(QString broadcasterID, QString id, bool refundPoints, + QString winningOutcomeID, + ResultCallback successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription void createEventSubSubscription( const eventsub::SubscriptionRequest &request, const QString &sessionID,