diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7badc0..b8b5a53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - 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 `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605) - 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) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 34280c9e..e4ef3f5e 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -437,6 +437,20 @@ public: FailureCallback failureCallback), (override)); + // get polls + MOCK_METHOD(void, getPolls, + (QString broadcasterID, QStringList ids, int first, + QString after, ResultCallback successCallback, + FailureCallback failureCallback), + (override)); + + // end poll + MOCK_METHOD(void, endPoll, + (QString broadcasterID, QString id, bool immediatelyHide, + ResultCallback successCallback, + FailureCallback failureCallback), + (override)); + // create prediction MOCK_METHOD(void, createPrediction, (QString broadcasterID, QString title, QStringList outcomes, diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index e2f5aecd..866a02f5 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -484,6 +484,9 @@ CommandController::CommandController(const Paths &paths) this->registerCommand("/shoutout", &commands::sendShoutout); this->registerCommand("/poll", &commands::createPoll); + this->registerCommand("/cancelpoll", &commands::cancelPoll); + this->registerCommand("/endpoll", &commands::endPoll); + this->registerCommand("/prediction", &commands::createPrediction); this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); diff --git a/src/controllers/commands/builtin/twitch/Poll.cpp b/src/controllers/commands/builtin/twitch/Poll.cpp index 8913299e..62726c44 100644 --- a/src/controllers/commands/builtin/twitch/Poll.cpp +++ b/src/controllers/commands/builtin/twitch/Poll.cpp @@ -67,4 +67,164 @@ QString createPoll(const CommandContext &ctx) return ""; } +QString endPoll(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + const auto err = QStringLiteral( + "The /endpoll 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 end a poll!"); + return ""; + } + + const auto roomId = ctx.twitchChannel->roomId(); + getHelix()->getPolls( + roomId, {}, 1, {}, + [channel = ctx.channel, roomId](const auto &result) { + if (result.polls.empty()) + { + channel->addSystemMessage("Failed to find any polls"); + return; + } + + auto poll = result.polls.front(); + if (poll.status != "ACTIVE") + { + channel->addSystemMessage("Could not find an active poll"); + return; + } + + getHelix()->endPoll( + roomId, poll.id, false, + [channel](const HelixPoll &data) { + // find most popular choice + HelixPollChoice winner = data.choices.front(); + int totalVotes = 0; + int winnerCount = 0; + for (const auto &choice : data.choices) + { + totalVotes += choice.votes; + if (choice.votes > winner.votes) + { + winner = choice; + winnerCount = 1; + } + else if (choice.votes == winner.votes) + { + winnerCount++; + } + } + + if (totalVotes == 0) + { + channel->addSystemMessage( + QString("Poll ended with zero votes: '%1'") + .arg(data.title)); + return; + } + + if (winnerCount > 1) + { + channel->addSystemMessage( + QString("Poll ended in a draw: '%1'") + .arg(data.title)); + return; + } + + const double percent = + 100.0 * winner.votes / std::max(totalVotes, 1); + + channel->addSystemMessage( + QString( + "Ended poll: '%1' - '%2' won with %3 votes (%4%)") + .arg(data.title, winner.title, + QString::number(winner.votes), + QString::number(percent, 'f', 1))); + }, + [channel](const auto &error) { + channel->addSystemMessage("Failed to end the poll - " + + error); + }); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to query polls - " + error); + }); + + return ""; +} + +QString cancelPoll(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + const auto err = QStringLiteral( + "The /cancelpoll 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 poll!"); + return ""; + } + + const auto roomId = ctx.twitchChannel->roomId(); + getHelix()->getPolls( + roomId, {}, 1, {}, + [channel = ctx.channel, roomId](const auto &result) { + if (result.polls.empty()) + { + channel->addSystemMessage("Failed to find any polls"); + return; + } + + auto poll = result.polls.front(); + if (poll.status != "ACTIVE") + { + channel->addSystemMessage("Could not find an active poll"); + return; + } + + getHelix()->endPoll( + roomId, poll.id, true, + [channel](const HelixPoll &data) { + channel->addSystemMessage( + QString("Canceled poll: '%1'").arg(data.title)); + }, + [channel](const auto &error) { + channel->addSystemMessage("Failed to cancel the poll - " + + error); + }); + }, + [channel = ctx.channel](const auto &error) { + channel->addSystemMessage("Failed to query polls - " + error); + }); + + return ""; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Poll.hpp b/src/controllers/commands/builtin/twitch/Poll.hpp index e76d8ecd..6ab4b4f4 100644 --- a/src/controllers/commands/builtin/twitch/Poll.hpp +++ b/src/controllers/commands/builtin/twitch/Poll.hpp @@ -13,4 +13,10 @@ namespace chatterino::commands { /// /poll QString createPoll(const CommandContext &ctx); +/// /endpoll +QString endPoll(const CommandContext &ctx); + +/// /cancelpoll +QString cancelPoll(const CommandContext &ctx); + } // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 4bc5a506..a85f38ab 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -3276,6 +3276,103 @@ void Helix::createPoll(QString broadcasterID, QString title, .execute(); } +void Helix::getPolls(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("polls", urlQuery) + .onSuccess([successCallback](const auto &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting polls was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + successCallback(HelixPolls(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(); +} + +void Helix::endPoll(const QString broadcasterID, const QString id, + const bool immediatelyHide, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject payload; + payload.insert("broadcaster_id", broadcasterID); + payload.insert("id", id); + payload.insert("status", immediatelyHide ? "ARCHIVED" : "TERMINATED"); + + this->makePatch("polls", {}) + .json(payload) + .onSuccess([successCallback](const NetworkResult &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for ending a poll was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + const auto data = HelixPolls(response); + successCallback(data.polls.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::createPrediction(const QString broadcasterID, const QString title, QStringList outcomes, const std::chrono::seconds duration, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index efbbf324..93c3810d 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -464,6 +464,55 @@ struct HelixSendMessageArgs { QString replyParentMessageID; }; +struct HelixPollChoice { + QString id; + QString title; + int votes; + + explicit HelixPollChoice(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , title(jsonObject.value("title").toString()) + , votes(jsonObject.value("votes").toInt()) + { + } +}; + +struct HelixPoll { + QString id; + QString title; + std::vector choices; + QString status; + + explicit HelixPoll(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , title(jsonObject.value("title").toString()) + , status(jsonObject.value("status").toString()) + { + for (const auto &data = jsonObject.value("choices").toArray(); + const auto &c : data) + { + HelixPollChoice choice(c.toObject()); + this->choices.push_back(choice); + } + } +}; + +struct HelixPolls { + std::vector polls; + + HelixPolls() = default; + + explicit HelixPolls(const QJsonObject &jsonObject) + { + for (const auto &data = jsonObject.value("data").toArray(); + const auto &p : data) + { + HelixPoll poll(p.toObject()); + this->polls.push_back(poll); + } + } +}; + enum class HelixAnnouncementColor { Blue, Green, @@ -1218,6 +1267,18 @@ public: int pointsPerVote, ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference#get-polls + virtual void getPolls(QString broadcasterID, QStringList ids, int first, + QString after, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + + /// https://dev.twitch.tv/docs/api/reference#end-poll + virtual void endPoll(QString broadcasterID, QString id, + bool immediatelyHide, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference#create-prediction virtual void createPrediction(QString broadcasterID, QString title, QStringList choices, @@ -1586,6 +1647,16 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference#get-polls + void getPolls(QString broadcasterID, QStringList ids, int first, + QString after, ResultCallback successCallback, + FailureCallback failureCallback) final; + + /// https://dev.twitch.tv/docs/api/reference#end-poll + void endPoll(QString broadcasterID, QString id, bool immediatelyHide, + ResultCallback successCallback, + FailureCallback failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference#create-prediction void createPrediction(QString broadcasterID, QString title, QStringList outcomes, std::chrono::seconds duration,