diff --git a/CHANGELOG.md b/CHANGELOG.md index 314884c26..bb1beeaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Minor: Moderators can now see when users are warned. (#5441) - Minor: Added support for Brave & google-chrome-stable browsers. (#5452) - Minor: Added drop indicator line while dragging in tables. (#5256) +- Minor: Added `/warn ` command for mods. This prevents the user from chatting until they acknowledge the warning. (#5474) - Minor: Introduce HTTP API for plugins. (#5383) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 0f0c41100..14ec3976e 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -326,6 +326,16 @@ public: (FailureCallback failureCallback)), (override)); // /timeout, /ban + // /warn + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, warnUser, + (QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /warn + // /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 4a7bcde67..2546d9a94 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -107,6 +107,8 @@ set(SOURCE_FILES controllers/commands/builtin/twitch/UpdateChannel.hpp controllers/commands/builtin/twitch/UpdateColor.cpp controllers/commands/builtin/twitch/UpdateColor.hpp + controllers/commands/builtin/twitch/Warn.cpp + controllers/commands/builtin/twitch/Warn.hpp controllers/commands/common/ChannelAction.cpp controllers/commands/common/ChannelAction.hpp controllers/commands/CommandContext.hpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a5554570a..af96ee9ee 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -26,6 +26,7 @@ #include "controllers/commands/builtin/twitch/Unban.hpp" #include "controllers/commands/builtin/twitch/UpdateChannel.hpp" #include "controllers/commands/builtin/twitch/UpdateColor.hpp" +#include "controllers/commands/builtin/twitch/Warn.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" @@ -439,6 +440,8 @@ void CommandController::initialize(Settings &, const Paths &paths) this->registerCommand("/ban", &commands::sendBan); this->registerCommand("/banid", &commands::sendBanById); + this->registerCommand("/warn", &commands::sendWarn); + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { this->registerCommand(cmd, &commands::sendWhisper); diff --git a/src/controllers/commands/builtin/twitch/Warn.cpp b/src/controllers/commands/builtin/twitch/Warn.cpp new file mode 100644 index 000000000..30cc54e6f --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Warn.cpp @@ -0,0 +1,199 @@ +#include "controllers/commands/builtin/twitch/Warn.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 "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace { + +using namespace chatterino; + +void warnUserByID(const ChannelPtr &channel, const QString &channelID, + const QString &sourceUserID, const QString &targetUserID, + const QString &reason, const QString &displayName) +{ + using Error = HelixWarnUserError; + + getHelix()->warnUser( + channelID, sourceUserID, targetUserID, reason, + [] { + // No response for warns, they're emitted over pubsub instead + }, + [channel, displayName](auto error, auto message) { + QString errorMessage = QString("Failed to warn user - "); + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting warn operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::CannotWarnUser: { + errorMessage += + QString("You cannot warn %1.").arg(displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +} // namespace + +namespace chatterino::commands { + +QString sendWarn(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/warn"); + const auto usage = QStringLiteral( + R"(Usage: "/warn [options...] " - Warn a user via their username. Reason is required and will be shown to the target user and other moderators. Options: --channel to override which channel the warn takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, false, true); + + if (!actions.has_value()) + { + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } + + return ""; + } + + assert(!actions.value().empty()); + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to warn someone!")); + return ""; + } + + for (const auto &action : actions.value()) + { + const auto &reason = action.reason; + if (reason.isEmpty()) + { + ctx.channel->addMessage( + makeSystemMessage("Failed to warn, you must specify a reason")); + break; + } + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Warn Action target username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.target.login); + } + else + { + // For hydration + userIDs.append(action.target.id); + } + if (action.channel.id.isEmpty()) + { + assert(!action.channel.login.isEmpty() && + "Warn Action channel username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.channel.login); + } + else + { + // For hydration + userIDs.append(action.channel.id); + } + + if (!userLoginsToFetch.isEmpty()) + { + // At least 1 user ID needs to be resolved before we can take action + // userIDs is filled up with the data we already have to hydrate the action channel & action target + getHelix()->fetchUsers( + userIDs, userLoginsToFetch, + [channel{ctx.channel}, actionChannel{action.channel}, + actionTarget{action.target}, currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + warnUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad username(s): %1") + .arg(userLoginsToFetch.join(", ")))); + }); + } + else + { + // If both IDs are available, we do no hydration & just use the id as the display name + warnUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, reason, + action.target.id); + } + } + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Warn.hpp b/src/controllers/commands/builtin/twitch/Warn.hpp new file mode 100644 index 000000000..42c78f564 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Warn.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /warn +QString sendWarn(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/providers/twitch/TwitchCommon.hpp b/src/providers/twitch/TwitchCommon.hpp index 19f538c2a..a5c388245 100644 --- a/src/providers/twitch/TwitchCommon.hpp +++ b/src/providers/twitch/TwitchCommon.hpp @@ -77,6 +77,7 @@ static const QStringList TWITCH_DEFAULT_COMMANDS{ "delete", "announce", "requests", + "warn", }; static const QStringList TWITCH_WHISPER_COMMANDS{"/w", ".w"}; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 460acfdb6..5b8c9fbfd 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1463,16 +1463,6 @@ void Helix::removeChannelVIP( .execute(); } -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch void Helix::unbanUser( QString broadcasterID, QString moderatorID, QString userID, ResultCallback<> successCallback, @@ -1572,18 +1562,7 @@ void Helix::unbanUser( } }) .execute(); -} // These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch +} void Helix::startRaid( QString fromBroadcasterID, QString toBroadcasterID, @@ -2266,6 +2245,107 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, .execute(); } +// Warn a user +// https://dev.twitch.tv/docs/api/reference#warn-chat-user +void Helix::warnUser( + QString broadcasterID, QString moderatorID, QString userID, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixWarnUserError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + { + QJsonObject data; + data["reason"] = reason; + data["user_id"] = userID; + + payload["data"] = data; + } + + this->makePost("moderation/warnings", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for warning a 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(Error::Unknown, result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (*result.status()) + { + case 400: { + if (message.startsWith("The user specified in the user_id " + "field may not be warned", + Qt::CaseInsensitive)) + { + failureCallback(Error::CannotWarnUser, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 409: { + failureCallback(Error::ConflictingOperation, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error warning user:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .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 c24e44bd3..346c9f3c3 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -621,6 +621,18 @@ enum class HelixBanUserError { // /timeout, /ban Forwarded, }; // /timeout, /ban +enum class HelixWarnUserError { // /warn + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + ConflictingOperation, + CannotWarnUser, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /warn + enum class HelixWhisperError { // /w Unknown, UserMissingScope, @@ -1024,6 +1036,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Warn a user + // https://dev.twitch.tv/docs/api/reference#warn-chat-user + virtual void warnUser( + QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper virtual void sendWhisper( @@ -1346,6 +1365,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Warn a user + // https://dev.twitch.tv/docs/api/reference#warn-chat-user + void warnUser( + QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper void sendWhisper( diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 23509c94b..18e9d6e4a 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -178,13 +178,21 @@ Used in: - `controllers/commands/CommandController.cpp` to send Twitch native shoutout using "/shoutout " +### Warn Chat User + +URL: https://dev.twitch.tv/docs/api/reference/#warn-chat-user + +Used in: + +- `controllers/commands/CommandController.cpp` to warn users via "/warn" command + ## PubSub ### Whispers We listen to the `whispers.` PubSub topic to receive information about incoming whispers to the user -No EventSub alternative available. +The EventSub alternative (`user.whisper.message`) is not yet implemented. ### Chat Moderator Actions @@ -192,25 +200,17 @@ We listen to the `chat_moderator_actions..` PubSub topic to We listen to this topic in every channel the user is a moderator. -No complete EventSub alternative available yet. Some functionality can be pieced together but it would not be zero cost, causing the `max_total_cost` of 10 to cause issues. +We have not yet migrated to the EventSub equivalent topics: -- For showing bans & timeouts: `channel.ban`, but does not work with moderator token??? -- For showing unbans & untimeouts: `channel.unban`, but does not work with moderator token??? -- Clear/delete message: not in eventsub, and IRC doesn't tell us which mod performed the action -- Roomstate (slow(off), followers(off), r9k(off), emoteonly(off), subscribers(off)) => not in eventsub, and IRC doesn't tell us which mod performed the action -- VIP added => not in eventsub, but not critical -- VIP removed => not in eventsub, but not critical -- Moderator added => channel.moderator.add eventsub, but doesn't work with moderator token -- Moderator removed => channel.moderator.remove eventsub, but doesn't work with moderator token -- Raid started => channel.raid eventsub, but cost=1 for moderator token -- Unraid => not in eventsub -- Add permitted term => not in eventsub -- Delete permitted term => not in eventsub -- Add blocked term => not in eventsub -- Delete blocked term => not in eventsub -- Modified automod properties => not in eventsub -- Approve unban request => cannot read moderator message in eventsub -- Deny unban request => not in eventsub +- For showing bans & timeouts => `channel.moderate` +- For showing unbans & untimeouts => `channel.moderate` +- Clear/delete message => `channel.moderate` +- Roomstate (slow(off), followers(off), r9k(off), emoteonly(off), subscribers(off)) => `channel.moderate` +- VIP/Moderator added/removed => `channel.moderate` +- Raid started/cancelled => `channel.moderate` +- Add/delete permitted/blocked term => `channel.moderate` (or `automod.terms.update`) +- Modified automod properties => `automod.settings.update` +- Approve/deny unban request => `channel.moderate` (or `channel.unban_request.resolve`) ### AutoMod Queue @@ -218,7 +218,7 @@ We listen to the `automod-queue..` PubSub topic to rec We listen to this topic in every channel the user is a moderator. -No EventSub alternative available yet. +The EventSub alternative (`automod.message.hold` and `automod.message.update`) is not yet implemented. ### Channel Point Rewards @@ -230,4 +230,4 @@ The EventSub alternative requires broadcaster auth, which is not a feasible alte We want to listen to the `low-trust-users` PubSub topic to receive information about messages from users who are marked as low-trust. -There is no EventSub alternative available yet. +The EventSub alternative (`channel.suspicious_user.message` and `channel.suspicious_user.update`) is not yet implemented.