From a2ba163a441e26b12e4b863226b436356dbd6dee Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:58:45 -0600 Subject: [PATCH] feat: add /monitor and /restrict commands for moderators (#6750) Reviewed-by: pajlada --- CHANGELOG.md | 1 + mocks/include/mocks/Helix.hpp | 14 ++ src/CMakeLists.txt | 2 + .../commands/CommandController.cpp | 6 + .../commands/builtin/twitch/LowTrust.cpp | 173 ++++++++++++++++++ .../commands/builtin/twitch/LowTrust.hpp | 29 +++ src/providers/twitch/api/Helix.cpp | 92 ++++++++++ src/providers/twitch/api/Helix.hpp | 27 +++ 8 files changed, 344 insertions(+) create mode 100644 src/controllers/commands/builtin/twitch/LowTrust.cpp create mode 100644 src/controllers/commands/builtin/twitch/LowTrust.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416e803..5a7c7d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Minor: Added message read/update methods to the `Channel` plugin API. (#6650) - Minor: Added action to reset `/watching`. (#6759) - Minor: Removed messaging about running flatpak. This is already apparent in the newer Flatpak runtimes. (#6768) +- Minor: Add `/(un)monitor` and `/(un)restrict` commands for moderators. (#6750) - Bugfix: Moderation checks now include the lead moderator badge. (#6642) - Bugfix: Fixed lead moderator badges not being filtered by the `Channel` badge setting. (#6665) - Bugfix: Expose the "Extra extension IDs" setting on non-Windows systems too. (#6509) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index a4fb01ac..57487cd3 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -339,6 +339,20 @@ public: (FailureCallback failureCallback)), (override)); // /warn + // /monitor and /restrict + MOCK_METHOD(void, addSuspiciousUser, + (QString broadcasterID, QString moderatorID, QString userID, + bool restricted, ResultCallback<> successCallback, + FailureCallback failureCallback), + (override)); // /monitor and /restrict + + // /unmonitor and /unrestrict + MOCK_METHOD(void, removeSuspiciousUser, + (QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback), + (override)); // /unmonitor and /unrestrict + // /w // The extra parenthesis around the failure callback is because its type // contains a comma diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8fb11f62..b79e9b9e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -99,6 +99,8 @@ 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/LowTrust.cpp + controllers/commands/builtin/twitch/LowTrust.hpp controllers/commands/builtin/twitch/Poll.cpp controllers/commands/builtin/twitch/Poll.hpp controllers/commands/builtin/twitch/Prediction.cpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 4be1fd20..31593cdd 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -19,6 +19,7 @@ #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/LowTrust.hpp" #include "controllers/commands/builtin/twitch/Poll.hpp" #include "controllers/commands/builtin/twitch/Prediction.hpp" #include "controllers/commands/builtin/twitch/Raid.hpp" @@ -451,6 +452,11 @@ CommandController::CommandController(const Paths &paths) this->registerCommand("/warn", &commands::sendWarn); + this->registerCommand("/monitor", &commands::monitorUser); + this->registerCommand("/restrict", &commands::restrictUser); + this->registerCommand("/unmonitor", &commands::unmonitorUser); + this->registerCommand("/unrestrict", &commands::unrestrictUser); + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { this->registerCommand(cmd, &commands::sendWhisper); diff --git a/src/controllers/commands/builtin/twitch/LowTrust.cpp b/src/controllers/commands/builtin/twitch/LowTrust.cpp new file mode 100644 index 00000000..2b048bb3 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/LowTrust.cpp @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Contributors to Chatterino +// +// SPDX-License-Identifier: MIT + +#include "controllers/commands/builtin/twitch/LowTrust.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace { + +using namespace chatterino; + +void addSuspiciousTreatment(const CommandContext &ctx, const QString &command, + const QString &usage, bool restrict) +{ + if (ctx.twitchChannel == nullptr) + { + // This action must be performed with a twitch channel as a context + const QString error = + "The " % command % " command only works in Twitch channels"; + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(error); + } + else + { + qCWarning(chatterinoCommands) << "Error parsing command:" << error; + } + return; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addSystemMessage(usage); + return; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage("You must be logged in to " % command % + " someone!"); + return; + } + + auto roomId = ctx.twitchChannel->roomId(); + auto modId = currentUser->getUserId(); + getHelix()->getUserByName( + ctx.words.at(1), + [chan{ctx.channel}, roomId, modId, command, restrict](const auto &u) { + getHelix()->addSuspiciousUser( + roomId, modId, u.id, restrict, + [] { + // treatment notification is handled by eventsub + }, + [chan, command](const auto &err) { + chan->addSystemMessage("Failed to " % command % " user - " % + err); + }); + }, + [chan = ctx.channel, command] { + chan->addSystemMessage("Failed to query user to " % command); + }); +} + +void removeSuspiciousTreatment(const CommandContext &ctx, + const QString &command, const QString &usage) +{ + if (ctx.twitchChannel == nullptr) + { + // This action must be performed with a twitch channel as a context + const QString error = + "The " % command % " command only works in Twitch channels"; + if (ctx.channel != nullptr) + { + ctx.channel->addSystemMessage(error); + } + else + { + qCWarning(chatterinoCommands) << "Error parsing command:" << error; + } + return; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addSystemMessage(usage); + return; + } + + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addSystemMessage("You must be logged in to " % command % + " someone!"); + return; + } + + auto roomId = ctx.twitchChannel->roomId(); + auto modId = currentUser->getUserId(); + getHelix()->getUserByName( + ctx.words.at(1), + [chan{ctx.channel}, roomId, modId, command](const auto &user) { + getHelix()->removeSuspiciousUser( + roomId, modId, user.id, + [] { + // treatment notification is handled by eventsub + }, + [chan, command](const auto &err) { + chan->addSystemMessage("Failed to " % command % " user - " % + err); + }); + }, + [chan = ctx.channel, command] { + chan->addSystemMessage("Failed to query user to " % command); + }); +} + +} // namespace + +namespace chatterino::commands { + +QString monitorUser(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/monitor"); + const auto usage = QStringLiteral( + R"(Usage: "/monitor " - Mark a user as monitored.)"); + + addSuspiciousTreatment(ctx, command, usage, false); + + return ""; +} + +QString restrictUser(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/restrict"); + const auto usage = QStringLiteral( + R"(Usage: "/restrict " - Mark a user as restricted.)"); + + addSuspiciousTreatment(ctx, command, usage, true); + + return ""; +} + +QString unmonitorUser(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/unmonitor"); + const auto usage = QStringLiteral( + R"(Usage: "/unmonitor " - Remove a user from suspicious treatment.)"); + + removeSuspiciousTreatment(ctx, command, usage); + + return ""; +} + +QString unrestrictUser(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/unrestrict"); + const auto usage = QStringLiteral( + R"(Usage: "/unrestrict " - Remove a user from suspicious treatment.)"); + + removeSuspiciousTreatment(ctx, command, usage); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/LowTrust.hpp b/src/controllers/commands/builtin/twitch/LowTrust.hpp new file mode 100644 index 00000000..d65f3e35 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/LowTrust.hpp @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Contributors to Chatterino +// +// SPDX-License-Identifier: MIT + +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /monitor +QString monitorUser(const CommandContext &ctx); + +/// /restrict +QString restrictUser(const CommandContext &ctx); + +/// /unmonitor +QString unmonitorUser(const CommandContext &ctx); + +/// /unrestrict +QString unrestrictUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index c23f834f..b15531e2 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2393,6 +2393,98 @@ void Helix::warnUser( .execute(); } +void Helix::addSuspiciousUser(const QString broadcasterID, + const QString moderatorID, const QString userID, + const bool restricted, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + payload["user_id"] = userID; + payload["status"] = restricted ? "RESTRICTED" : "ACTIVE_MONITORING"; + + this->makePost("moderation/suspicious_users", urlQuery) + .json(payload) + .onSuccess([successCallback](const auto &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for treating a suspicious user was" + << result.formatError() << "but we expected it to be 200"; + } + // we don't care about the response + successCallback(); + }) + .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::removeSuspiciousUser(const QString broadcasterID, + const QString moderatorID, + const QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + urlQuery.addQueryItem("user_id", userID); + + this->makeDelete("moderation/suspicious_users", urlQuery) + .onSuccess([successCallback](const auto &result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for un-treating a suspicious user was" + << result.formatError() << "but we expected it to be 200"; + } + // we don't care about the response + successCallback(); + }) + .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(); +} + // https://dev.twitch.tv/docs/api/reference#send-whisper void Helix::sendWhisper( QString fromUserID, QString toUserID, QString message, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 8ab63a00..a7d7d6f0 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -1238,6 +1238,20 @@ public: QString reason, ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Monitor or restrict a user + // https://dev.twitch.tv/docs/api/reference/#add-suspicious-status-to-chat-user + virtual void addSuspiciousUser( + QString broadcasterID, QString moderatorID, QString userID, + bool restricted, ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + + // Remove a user from monitored or restricted suspicious treatment + // https://dev.twitch.tv/docs/api/reference/#remove-suspicious-status-from-chat-user + virtual void removeSuspiciousUser( + QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper virtual void sendWhisper( @@ -1632,6 +1646,19 @@ public: QString reason, ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Monitor or restrict a user + // https://dev.twitch.tv/docs/api/reference/#add-suspicious-status-to-chat-user + void addSuspiciousUser(QString broadcasterID, QString moderatorID, + QString userID, bool restricted, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + + // Remove a user from monitored or restricted suspicious treatment + // https://dev.twitch.tv/docs/api/reference/#remove-suspicious-status-from-chat-user + void removeSuspiciousUser(QString broadcasterID, QString moderatorID, + QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper void sendWhisper(