feat: add /monitor and /restrict commands for moderators (#6750)

Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
iProdigy
2026-01-25 11:58:45 -06:00
committed by GitHub
parent d6bd0090a4
commit a2ba163a44
8 changed files with 344 additions and 0 deletions
+1
View File
@@ -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)
+14
View File
@@ -339,6 +339,20 @@ public:
(FailureCallback<HelixWarnUserError, QString> failureCallback)),
(override)); // /warn
// /monitor and /restrict
MOCK_METHOD(void, addSuspiciousUser,
(QString broadcasterID, QString moderatorID, QString userID,
bool restricted, ResultCallback<> successCallback,
FailureCallback<QString> failureCallback),
(override)); // /monitor and /restrict
// /unmonitor and /unrestrict
MOCK_METHOD(void, removeSuspiciousUser,
(QString broadcasterID, QString moderatorID, QString userID,
ResultCallback<> successCallback,
FailureCallback<QString> failureCallback),
(override)); // /unmonitor and /unrestrict
// /w
// The extra parenthesis around the failure callback is because its type
// contains a comma
+2
View File
@@ -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
@@ -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);
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// 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 <username>" - 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 <username>" - 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 <username>" - 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 <username>" - Remove a user from suspicious treatment.)");
removeSuspiciousTreatment(ctx, command, usage);
return "";
}
} // namespace chatterino::commands
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// 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
+92
View File
@@ -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<QString> 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<QString> 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,
+27
View File
@@ -1238,6 +1238,20 @@ public:
QString reason, ResultCallback<> successCallback,
FailureCallback<HelixWarnUserError, QString> 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<QString> 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<QString> 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<HelixWarnUserError, QString> 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<QString> 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<QString> failureCallback) final;
// Send a whisper
// https://dev.twitch.tv/docs/api/reference#send-whisper
void sendWhisper(