From 9b31246502df1be0f70bf3ce8dd25e6dd080c8f2 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 16 Jun 2024 12:22:51 +0200 Subject: [PATCH] feat: allow timeout-related commands to be used in multiple channels (#5402) This changes the behaviour of the following commands: - `/ban` - `/timeout` - `/untimeout` - `/unban` All of those commands now accept one or more `--channel` parameters to override which channel the action should take place in. The `--channel` parameter accepts a channel ID or channel name with the same syntax as the other "user targets" do (e.g. `id:11148817` or `pajlada`) examples Ban user in the chat you're typing in: `/ban weeb123` Ban user in the chat you're typing in, with a reason specified: `/ban weeb123 the ban reason` Ban user in a separate chat, with a reason specified: `/ban --channel pajlada weeb123 the ban reason` Ban user in two separate chats, with a reason specified: `/ban --channel pajlada --channel id:117166826 weeb123 the ban reason` Timeout user in the chat you're typing in: `/timeout weeb123` Timeout user in the chat you're typing in, with a reason specified: `/timeout weeb123 10m the timeout reason` Timeout user in a separate chat, with a reason specified: `/timeout --channel pajlada weeb123 10m the timeout reason` Timeout user in two separate chats, with a reason specified: `/timeout --channel pajlada --channel id:117166826 weeb123 10m the timeout reason` Unban user in the chat you're typing in: `/unban weeb123` Unban user in a separate chat: `/unban --channel pajlada weeb123` Unban user in two separate chats: `/unban --channel pajlada --channel id:117166826 weeb123` --- .gitmodules | 3 + CHANGELOG.md | 1 + benchmarks/src/RecentMessages.cpp | 7 + lib/expected-lite | 1 + mocks/include/mocks/EmptyApplication.hpp | 7 +- mocks/include/mocks/LinkResolver.hpp | 32 + mocks/include/mocks/Logging.hpp | 36 + resources/licenses/expected-lite.txt | 23 + src/Application.cpp | 2 +- src/Application.hpp | 12 +- src/CMakeLists.txt | 5 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 3 +- .../commands/builtin/twitch/Ban.cpp | 290 +++-- .../commands/builtin/twitch/Unban.cpp | 140 ++- .../commands/common/ChannelAction.cpp | 184 +++ .../commands/common/ChannelAction.hpp | 59 + src/providers/twitch/TwitchChannel.cpp | 25 +- src/providers/twitch/TwitchChannel.hpp | 1 + src/singletons/Logging.hpp | 13 +- src/widgets/settingspages/AboutPage.cpp | 3 + tests/CMakeLists.txt | 1 + tests/src/Commands.cpp | 1057 +++++++++++++++++ 23 files changed, 1732 insertions(+), 174 deletions(-) create mode 160000 lib/expected-lite create mode 100644 mocks/include/mocks/LinkResolver.hpp create mode 100644 mocks/include/mocks/Logging.hpp create mode 100644 resources/licenses/expected-lite.txt create mode 100644 src/controllers/commands/common/ChannelAction.cpp create mode 100644 src/controllers/commands/common/ChannelAction.hpp create mode 100644 tests/src/Commands.cpp diff --git a/.gitmodules b/.gitmodules index cb1235a85..e58a5bbd4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,6 @@ [submodule "tools/crash-handler"] path = tools/crash-handler url = https://github.com/Chatterino/crash-handler +[submodule "lib/expected-lite"] + path = lib/expected-lite + url = https://github.com/martinmoene/expected-lite diff --git a/CHANGELOG.md b/CHANGELOG.md index e790eb912..e49cea9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: Add option to customise Moderation buttons with images. (#5369) - Minor: Colored usernames now update on the fly when changing the "Color @usernames" setting. (#5300) - Minor: Added `flags.action` filter variable, allowing you to filter on `/me` messages. (#5397) +- Minor: Added the ability for `/ban`, `/timeout`, `/unban`, and `/untimeout` to specify multiple channels to duplicate the action to. Example: `/timeout --channel id:11148817 --channel testaccount_420 forsen 7m game complaining`. (#5402) - Minor: The size of the emote popup is now saved. (#5415) - Minor: Added the ability to duplicate tabs. (#5277) - Minor: Improved error messages for channel update commands. (#5429) diff --git a/benchmarks/src/RecentMessages.cpp b/benchmarks/src/RecentMessages.cpp index fd5fe0f1a..7f2c0c7ac 100644 --- a/benchmarks/src/RecentMessages.cpp +++ b/benchmarks/src/RecentMessages.cpp @@ -4,6 +4,7 @@ #include "messages/Emote.hpp" #include "mocks/DisabledStreamerMode.hpp" #include "mocks/EmptyApplication.hpp" +#include "mocks/LinkResolver.hpp" #include "mocks/TwitchIrcServer.hpp" #include "mocks/UserData.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -99,10 +100,16 @@ public: return &this->streamerMode; } + ILinkResolver *getLinkResolver() override + { + return &this->linkResolver; + } + AccountController accounts; Emotes emotes; mock::UserDataController userData; mock::MockTwitchIrcServer twitch; + mock::EmptyLinkResolver linkResolver; ChatterinoBadges chatterinoBadges; FfzBadges ffzBadges; SeventvBadges seventvBadges; diff --git a/lib/expected-lite b/lib/expected-lite new file mode 160000 index 000000000..3634b0a6d --- /dev/null +++ b/lib/expected-lite @@ -0,0 +1 @@ +Subproject commit 3634b0a6d8dffcffad4d1355253d79290c0c754c diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 54906f56c..233211bfa 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -19,6 +19,11 @@ public: virtual ~EmptyApplication() = default; + bool isTest() const override + { + return true; + } + const Paths &getPaths() override { return this->paths_; @@ -137,7 +142,7 @@ public: return nullptr; } - Logging *getChatLogger() override + ILogging *getChatLogger() override { assert(!"getChatLogger was called without being initialized"); return nullptr; diff --git a/mocks/include/mocks/LinkResolver.hpp b/mocks/include/mocks/LinkResolver.hpp new file mode 100644 index 000000000..8a5682a3d --- /dev/null +++ b/mocks/include/mocks/LinkResolver.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "providers/links/LinkResolver.hpp" + +#include +#include +#include + +namespace chatterino::mock { + +class LinkResolver : public ILinkResolver +{ +public: + LinkResolver() = default; + ~LinkResolver() override = default; + + MOCK_METHOD(void, resolve, (LinkInfo * info), (override)); +}; + +class EmptyLinkResolver : public ILinkResolver +{ +public: + EmptyLinkResolver() = default; + ~EmptyLinkResolver() override = default; + + void resolve(LinkInfo *info) override + { + // + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/Logging.hpp b/mocks/include/mocks/Logging.hpp new file mode 100644 index 000000000..8d444142b --- /dev/null +++ b/mocks/include/mocks/Logging.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "singletons/Logging.hpp" + +#include +#include +#include + +namespace chatterino::mock { + +class Logging : public ILogging +{ +public: + Logging() = default; + ~Logging() override = default; + + MOCK_METHOD(void, addMessage, + (const QString &channelName, MessagePtr message, + const QString &platformName), + (override)); +}; + +class EmptyLogging : public ILogging +{ +public: + EmptyLogging() = default; + ~EmptyLogging() override = default; + + void addMessage(const QString &channelName, MessagePtr message, + const QString &platformName) override + { + // + } +}; + +} // namespace chatterino::mock diff --git a/resources/licenses/expected-lite.txt b/resources/licenses/expected-lite.txt new file mode 100644 index 000000000..36b7cd93c --- /dev/null +++ b/resources/licenses/expected-lite.txt @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/src/Application.cpp b/src/Application.cpp index 77663923f..5a9323921 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -504,7 +504,7 @@ PubSub *Application::getTwitchPubSub() return this->twitchPubSub.get(); } -Logging *Application::getChatLogger() +ILogging *Application::getChatLogger() { assertInGuiThread(); diff --git a/src/Application.hpp b/src/Application.hpp index 795a9f6d9..d2c0e2fac 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -35,6 +35,7 @@ class PluginController; class Theme; class WindowManager; +class ILogging; class Logging; class Paths; class Emotes; @@ -64,6 +65,8 @@ public: static IApplication *instance; + virtual bool isTest() const = 0; + virtual const Paths &getPaths() = 0; virtual const Args &getArgs() = 0; virtual Theme *getThemes() = 0; @@ -80,7 +83,7 @@ public: virtual ITwitchIrcServer *getTwitch() = 0; virtual IAbstractIrcServer *getTwitchAbstract() = 0; virtual PubSub *getTwitchPubSub() = 0; - virtual Logging *getChatLogger() = 0; + virtual ILogging *getChatLogger() = 0; virtual IChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; virtual SeventvBadges *getSeventvBadges() = 0; @@ -121,6 +124,11 @@ public: Application &operator=(const Application &) = delete; Application &operator=(Application &&) = delete; + bool isTest() const override + { + return false; + } + /** * In the interim, before we remove _exit(0); from RunGui.cpp, * this will destroy things we know can be destroyed @@ -191,7 +199,7 @@ public: ITwitchIrcServer *getTwitch() override; IAbstractIrcServer *getTwitchAbstract() override; PubSub *getTwitchPubSub() override; - Logging *getChatLogger() override; + ILogging *getChatLogger() override; FfzBadges *getFfzBadges() override; SeventvBadges *getSeventvBadges() override; IUserDataController *getUserData() override; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 72fa4851a..da690e43a 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/common/ChannelAction.cpp + controllers/commands/common/ChannelAction.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp @@ -1003,6 +1005,9 @@ target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} # semver dependency https://github.com/Neargye/semver target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/semver/include) +# expected-lite dependency https://github.com/martinmoene/expected-lite +target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/expected-lite/include) + # miniaudio dependency https://github.com/mackron/miniaudio if (USE_SYSTEM_MINIAUDIO) message(STATUS "Building with system miniaudio") diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index de4ef056c..a8cd8285d 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -11,6 +11,7 @@ Q_LOGGING_CATEGORY(chatterinoArgs, "chatterino.args", logThreshold); Q_LOGGING_CATEGORY(chatterinoBenchmark, "chatterino.benchmark", logThreshold); Q_LOGGING_CATEGORY(chatterinoBttv, "chatterino.bttv", logThreshold); Q_LOGGING_CATEGORY(chatterinoCache, "chatterino.cache", logThreshold); +Q_LOGGING_CATEGORY(chatterinoCommands, "chatterino.commands", logThreshold); Q_LOGGING_CATEGORY(chatterinoCommon, "chatterino.common", logThreshold); Q_LOGGING_CATEGORY(chatterinoCrashhandler, "chatterino.crashhandler", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 36daa0e1e..b814bb332 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -7,6 +7,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoArgs); Q_DECLARE_LOGGING_CATEGORY(chatterinoBenchmark); Q_DECLARE_LOGGING_CATEGORY(chatterinoBttv); Q_DECLARE_LOGGING_CATEGORY(chatterinoCache); +Q_DECLARE_LOGGING_CATEGORY(chatterinoCommands); Q_DECLARE_LOGGING_CATEGORY(chatterinoCommon); Q_DECLARE_LOGGING_CATEGORY(chatterinoCrashhandler); Q_DECLARE_LOGGING_CATEGORY(chatterinoEmoji); @@ -17,6 +18,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoHighlights); Q_DECLARE_LOGGING_CATEGORY(chatterinoHotkeys); Q_DECLARE_LOGGING_CATEGORY(chatterinoHTTP); Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); +Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr); Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates); @@ -26,7 +28,6 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork); Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); -Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp index 27b3d5a46..bce3001f4 100644 --- a/src/controllers/commands/builtin/twitch/Ban.cpp +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -1,13 +1,14 @@ #include "controllers/commands/builtin/twitch/Ban.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" -#include "util/Twitch.hpp" namespace { @@ -80,13 +81,12 @@ QString formatBanTimeoutError(const char *operation, HelixBanUserError error, return errorMessage; } -void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, +void banUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, const QString &reason, const QString &displayName) { getHelix()->banUser( - twitchChannel->roomId(), sourceUserID, targetUserID, std::nullopt, - reason, + channelID, sourceUserID, targetUserID, std::nullopt, reason, [] { // No response for bans, they're emitted over pubsub/IRC instead }, @@ -97,14 +97,13 @@ void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, }); } -void timeoutUserByID(const ChannelPtr &channel, - const TwitchChannel *twitchChannel, +void timeoutUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, int duration, const QString &reason, const QString &displayName) { getHelix()->banUser( - twitchChannel->roomId(), sourceUserID, targetUserID, duration, reason, + channelID, sourceUserID, targetUserID, duration, reason, [] { // No response for timeouts, they're emitted over pubsub/IRC instead }, @@ -121,63 +120,108 @@ namespace chatterino::commands { QString sendBan(const CommandContext &ctx) { - const auto &words = ctx.words; - const auto &channel = ctx.channel; - const auto *twitchChannel = ctx.twitchChannel; + const auto command = QStringLiteral("/ban"); + const auto usage = QStringLiteral( + R"(Usage: "/ban [options...] [reason]" - Permanently prevent a user from chatting via their username. Reason is optional and will be shown to the target user and other moderators. Options: --channel to override which channel the ban takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, false, true); - if (channel == nullptr) + if (!actions.has_value()) { + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } + return ""; } - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /ban command only works in Twitch channels."))); - return ""; - } - - const auto *usageStr = - "Usage: \"/ban [reason]\" - Permanently prevent a user " - "from chatting. Reason is optional and will be shown to the target " - "user and other moderators. Use \"/unban\" to remove a ban."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } + assert(!actions.value().empty()); auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { - channel->addMessage( + ctx.channel->addMessage( makeSystemMessage("You must be logged in to ban someone!")); return ""; } - const auto &rawTarget = words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); - auto reason = words.mid(2).join(' '); + for (const auto &action : actions.value()) + { + const auto &reason = action.reason; - if (!targetUserID.isEmpty()) - { - banUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUserID, reason, targetUserID); - } - else - { - getHelix()->getUserByName( - targetUserName, - [channel, currentUser, twitchChannel, - reason](const auto &targetUser) { - banUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUser.id, reason, targetUser.displayName); - }, - [channel, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Ban 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() && + "Ban 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 ban, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to ban, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + banUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to ban, 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 + banUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, reason, + action.target.id); + } } return ""; @@ -221,87 +265,117 @@ QString sendBanById(const CommandContext &ctx) auto target = words.at(1); auto reason = words.mid(2).join(' '); - banUserByID(channel, twitchChannel, currentUser->getUserId(), target, - reason, target); + banUserByID(channel, twitchChannel->roomId(), currentUser->getUserId(), + target, reason, target); return ""; } QString sendTimeout(const CommandContext &ctx) { - const auto &words = ctx.words; - const auto &channel = ctx.channel; - const auto *twitchChannel = ctx.twitchChannel; + const auto command = QStringLiteral("/timeout"); + const auto usage = QStringLiteral( + R"(Usage: "/timeout [options...] [duration][time unit] [reason]" - Temporarily prevent a user from chatting. Duration (optional, default=10 minutes) must be a positive integer; time unit (optional, default=s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Combinations like 1d2h are also allowed. Reason is optional and will be shown to the target user and other moderators. Use "/untimeout" to remove a timeout. Options: --channel to override which channel the timeout takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, true, true); - if (channel == nullptr) + if (!actions.has_value()) { + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } + return ""; } - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /timeout command only works in Twitch channels."))); - return ""; - } - const auto *usageStr = - "Usage: \"/timeout [duration][time unit] [reason]\" - " - "Temporarily prevent a user from chatting. Duration (optional, " - "default=10 minutes) must be a positive integer; time unit " - "(optional, default=s) must be one of s, m, h, d, w; maximum " - "duration is 2 weeks. Combinations like 1d2h are also allowed. " - "Reason is optional and will be shown to the target user and other " - "moderators. Use \"/untimeout\" to remove a timeout."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } + assert(!actions.value().empty()); auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { - channel->addMessage( + ctx.channel->addMessage( makeSystemMessage("You must be logged in to timeout someone!")); return ""; } - const auto &rawTarget = words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); - - int duration = 10 * 60; // 10min - if (words.size() >= 3) + for (const auto &action : actions.value()) { - duration = (int)parseDurationToSeconds(words.at(2)); - if (duration <= 0) + const auto &reason = action.reason; + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; + assert(!action.target.login.isEmpty() && + "Timeout 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() && + "Timeout 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); } - } - auto reason = words.mid(3).join(' '); - if (!targetUserID.isEmpty()) - { - timeoutUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUserID, duration, reason, targetUserID); - } - else - { - getHelix()->getUserByName( - targetUserName, - [channel, currentUser, twitchChannel, - targetUserName{targetUserName}, duration, - reason](const auto &targetUser) { - timeoutUserByID(channel, twitchChannel, - currentUser->getUserId(), targetUser.id, - duration, reason, targetUser.displayName); - }, - [channel, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + 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}, duration{action.duration}, + actionChannel{action.channel}, actionTarget{action.target}, + currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + timeoutUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + duration, reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, 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 + timeoutUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, + action.duration, reason, action.target.id); + } } return ""; diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp index e88008e84..f47d70168 100644 --- a/src/controllers/commands/builtin/twitch/Unban.cpp +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -1,24 +1,25 @@ +#include "controllers/commands/builtin/twitch/Unban.hpp" + #include "Application.hpp" +#include "common/Channel.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/commands/builtin/twitch/Ban.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" -#include "util/Twitch.hpp" namespace { using namespace chatterino; -void unbanUserByID(const ChannelPtr &channel, - const TwitchChannel *twitchChannel, +void unbanUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, const QString &displayName) { getHelix()->unbanUser( - twitchChannel->roomId(), sourceUserID, targetUserID, + channelID, sourceUserID, targetUserID, [] { // No response for unbans, they're emitted over pubsub/IRC instead }, @@ -85,26 +86,28 @@ namespace chatterino::commands { QString unbanUser(const CommandContext &ctx) { - if (ctx.channel == nullptr) + const auto command = ctx.words.at(0).toLower(); + const auto usage = + QStringLiteral( + R"(Usage: "%1 - Removes a ban on a user. Options: --channel to override which channel the unban takes place in (can be specified multiple times).)") + .arg(command); + const auto actions = parseChannelAction(ctx, command, usage, false, false); + if (!actions.has_value()) { + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } + return ""; } - auto commandName = ctx.words.at(0).toLower(); - if (ctx.twitchChannel == nullptr) - { - ctx.channel->addMessage(makeSystemMessage( - QString("The %1 command only works in Twitch channels.") - .arg(commandName))); - return ""; - } - if (ctx.words.size() < 2) - { - ctx.channel->addMessage(makeSystemMessage( - QString("Usage: \"%1 \" - Removes a ban on a user.") - .arg(commandName))); - return ""; - } + assert(!actions.value().empty()); auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) @@ -114,29 +117,78 @@ QString unbanUser(const CommandContext &ctx) return ""; } - const auto &rawTarget = ctx.words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); + for (const auto &action : actions.value()) + { + const auto &reason = action.reason; - if (!targetUserID.isEmpty()) - { - unbanUserByID(ctx.channel, ctx.twitchChannel, currentUser->getUserId(), - targetUserID, targetUserID); - } - else - { - getHelix()->getUserByName( - targetUserName, - [channel{ctx.channel}, currentUser, - twitchChannel{ctx.twitchChannel}, - targetUserName{targetUserName}](const auto &targetUser) { - unbanUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUser.id, targetUser.displayName); - }, - [channel{ctx.channel}, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Unban 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() && + "Unban 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 timeout, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + unbanUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, 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 + unbanUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, + action.target.id); + } } return ""; diff --git a/src/controllers/commands/common/ChannelAction.cpp b/src/controllers/commands/common/ChannelAction.cpp new file mode 100644 index 000000000..487385920 --- /dev/null +++ b/src/controllers/commands/common/ChannelAction.cpp @@ -0,0 +1,184 @@ +#include "controllers/commands/common/ChannelAction.hpp" + +#include "controllers/commands/CommandContext.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" +#include "util/Twitch.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace chatterino::commands { + +bool IncompleteHelixUser::hydrateFrom(const std::vector &users) +{ + // Find user in list based on our id or login + auto resolvedIt = + std::find_if(users.begin(), users.end(), [this](const auto &user) { + if (!this->login.isEmpty()) + { + return user.login.compare(this->login, Qt::CaseInsensitive) == + 0; + } + if (!this->id.isEmpty()) + { + return user.id.compare(this->id, Qt::CaseInsensitive) == 0; + } + return false; + }); + if (resolvedIt == users.end()) + { + return false; + } + const auto &resolved = *resolvedIt; + this->id = resolved.id; + this->login = resolved.login; + this->displayName = resolved.displayName; + return true; +} + +std::ostream &operator<<(std::ostream &os, const IncompleteHelixUser &u) +{ + os << "{id:" << u.id.toStdString() << ", login:" << u.login.toStdString() + << ", displayName:" << u.displayName.toStdString() << '}'; + return os; +} + +void PrintTo(const PerformChannelAction &a, std::ostream *os) +{ + *os << "{channel:" << a.channel << ", target:" << a.target + << ", reason:" << a.reason.toStdString() + << ", duration:" << std::to_string(a.duration) << '}'; +} + +nonstd::expected, QString> parseChannelAction( + const CommandContext &ctx, const QString &command, const QString &usage, + bool withDuration, bool withReason) +{ + if (ctx.channel == nullptr) + { + // A ban action must be performed with a channel as a context + return nonstd::make_unexpected( + "A " % command % + " action must be performed with a channel as a context"); + } + + QCommandLineParser parser; + parser.setOptionsAfterPositionalArgumentsMode( + QCommandLineParser::ParseAsPositionalArguments); + parser.addPositionalArgument("username", "The name of the user to ban"); + if (withDuration) + { + parser.addPositionalArgument("duration", "Duration of the action"); + } + if (withReason) + { + parser.addPositionalArgument("reason", "The optional ban reason"); + } + QCommandLineOption channelOption( + "channel", "Override which channel(s) to perform the action in", + "channel"); + parser.addOptions({ + channelOption, + }); + parser.parse(ctx.words); + + auto positionalArguments = parser.positionalArguments(); + if (positionalArguments.isEmpty()) + { + return nonstd::make_unexpected("Missing target - " % usage); + } + + auto [targetUserName, targetUserID] = + parseUserNameOrID(positionalArguments.takeFirst()); + + PerformChannelAction base{ + .target = + IncompleteHelixUser{ + .id = targetUserID, + .login = targetUserName, + .displayName = "", + }, + .duration = 0, + }; + + if (withDuration) + { + if (positionalArguments.isEmpty()) + { + base.duration = 10 * 60; // 10 min + } + else + { + auto durationStr = positionalArguments.takeFirst(); + base.duration = (int)parseDurationToSeconds(durationStr); + if (base.duration <= 0) + { + return nonstd::make_unexpected("Invalid duration - " % usage); + } + if (withReason) + { + base.reason = positionalArguments.join(' '); + } + } + } + else + { + if (withReason) + { + base.reason = positionalArguments.join(' '); + } + } + + std::vector actions; + + auto overrideChannels = parser.values(channelOption); + if (overrideChannels.isEmpty()) + { + if (ctx.twitchChannel == nullptr) + { + return nonstd::make_unexpected( + "The " % command % " command only works in Twitch channels"); + } + + actions.push_back(PerformChannelAction{ + .channel = + { + .id = ctx.twitchChannel->roomId(), + .login = ctx.twitchChannel->getName(), + .displayName = ctx.twitchChannel->getDisplayName(), + }, + .target = base.target, + .reason = base.reason, + .duration = base.duration, + }); + } + else + { + for (const auto &overrideChannelTarget : overrideChannels) + { + auto [channelUserName, channelUserID] = + parseUserNameOrID(overrideChannelTarget); + actions.push_back(PerformChannelAction{ + .channel = + { + .id = channelUserID, + .login = channelUserName, + }, + .target = base.target, + .reason = base.reason, + .duration = base.duration, + }); + } + } + + return actions; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/common/ChannelAction.hpp b/src/controllers/commands/common/ChannelAction.hpp new file mode 100644 index 000000000..fd80eeb79 --- /dev/null +++ b/src/controllers/commands/common/ChannelAction.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace chatterino { + +struct CommandContext; +struct HelixUser; + +} // namespace chatterino + +namespace chatterino::commands { + +struct IncompleteHelixUser { + QString id; + QString login; + QString displayName; + + bool hydrateFrom(const std::vector &users); + + bool operator==(const IncompleteHelixUser &other) const + { + return std::tie(this->id, this->login, this->displayName) == + std::tie(other.id, other.login, other.displayName); + } +}; + +struct PerformChannelAction { + // Channel to perform the action in + IncompleteHelixUser channel; + // Target to perform the action on + IncompleteHelixUser target; + QString reason; + int duration{}; + + bool operator==(const PerformChannelAction &other) const + { + return std::tie(this->channel, this->target, this->reason, + this->duration) == std::tie(other.channel, other.target, + other.reason, + other.duration); + } +}; + +std::ostream &operator<<(std::ostream &os, const IncompleteHelixUser &u); +// gtest printer +// NOLINTNEXTLINE(readability-identifier-naming) +void PrintTo(const PerformChannelAction &a, std::ostream *os); + +nonstd::expected, QString> parseChannelAction( + const CommandContext &ctx, const QString &command, const QString &usage, + bool withDuration, bool withReason); + +} // namespace chatterino::commands diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 4339d6c2e..bb982c8fe 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -91,13 +91,6 @@ TwitchChannel::TwitchChannel(const QString &name) { qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened"; - if (!getApp()) - { - // This is intended for tests and benchmarks. - // Irc, Pubsub, live-updates, and live-notifications aren't mocked there. - return; - } - this->bSignals_.emplace_back( getIApp()->getAccounts()->twitch.currentUserChanged.connect([this] { this->setMod(false); @@ -231,13 +224,6 @@ TwitchChannel::TwitchChannel(const QString &name) TwitchChannel::~TwitchChannel() { - if (!getApp()) - { - // This is for tests and benchmarks, where live-updates aren't mocked - // see comment in constructor. - return; - } - getIApp()->getTwitch()->dropSeventvChannel(this->seventvUserID_, this->seventvEmoteSetID_); @@ -586,6 +572,10 @@ void TwitchChannel::showLoginMessage() void TwitchChannel::roomIdChanged() { + if (getIApp()->isTest()) + { + return; + } this->refreshPubSub(); this->refreshBadges(); this->refreshCheerEmotes(); @@ -792,7 +782,7 @@ void TwitchChannel::setRoomId(const QString &id) { *this->roomID_.access() = id; // This is intended for tests and benchmarks. See comment in constructor. - if (getApp()) + if (!getIApp()->isTest()) { this->roomIdChanged(); this->loadRecentMessages(); @@ -1341,6 +1331,11 @@ void TwitchChannel::loadRecentMessagesReconnect() void TwitchChannel::refreshPubSub() { + if (getIApp()->isTest()) + { + return; + } + auto roomId = this->roomId(); if (roomId.isEmpty()) { diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 2add54302..611fea1f9 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -463,6 +463,7 @@ private: friend class TwitchIrcServer; friend class TwitchMessageBuilder; friend class IrcMessageHandler; + friend class Commands_E2E_Test; }; } // namespace chatterino diff --git a/src/singletons/Logging.hpp b/src/singletons/Logging.hpp index edd1ac07f..af86a702d 100644 --- a/src/singletons/Logging.hpp +++ b/src/singletons/Logging.hpp @@ -16,13 +16,22 @@ struct Message; using MessagePtr = std::shared_ptr; class LoggingChannel; -class Logging +class ILogging +{ +public: + virtual ~ILogging() = default; + + virtual void addMessage(const QString &channelName, MessagePtr message, + const QString &platformName) = 0; +}; + +class Logging : public ILogging { public: Logging(Settings &settings); void addMessage(const QString &channelName, MessagePtr message, - const QString &platformName); + const QString &platformName) override; private: using PlatformName = QString; diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 89c985c5e..bc503ca61 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -127,6 +127,9 @@ AboutPage::AboutPage() addLicense(form.getElement(), "Fluent icons", "https://github.com/microsoft/fluentui-system-icons", ":/licenses/fluenticons.txt"); + addLicense(form.getElement(), "expected-lite", + "https://github.com/martinmoene/expected-lite", + ":/licenses/expected-lite.txt"); } // Attributions diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8288664df..19ac9195b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/QMagicEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ModerationAction.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Scrollbar.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp # Add your new file above this line! ) diff --git a/tests/src/Commands.cpp b/tests/src/Commands.cpp new file mode 100644 index 000000000..f180a7180 --- /dev/null +++ b/tests/src/Commands.cpp @@ -0,0 +1,1057 @@ +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/commands/common/ChannelAction.hpp" +#include "mocks/EmptyApplication.hpp" +#include "mocks/Helix.hpp" +#include "mocks/Logging.hpp" +#include "mocks/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Settings.hpp" +#include "Test.hpp" + +#include + +using namespace chatterino; + +using ::testing::_; +using ::testing::StrictMock; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + MockApplication() + : settings(this->settingsDir.filePath("settings.json")) + { + } + + ITwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + AccountController *getAccounts() override + { + return &this->accounts; + } + + CommandController *getCommands() override + { + return &this->commands; + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + ILogging *getChatLogger() override + { + return &this->chatLogger; + } + + Settings settings; + AccountController accounts; + CommandController commands; + mock::MockTwitchIrcServer twitch; + Emotes emotes; + mock::EmptyLogging chatLogger; +}; + +} // namespace + +namespace chatterino { + +TEST(Commands, parseBanActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/ban"); + QString usage("usage string"); + bool withDuration = false; + bool withReason = true; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal ban with an added reason, with the user maybe trying to use the --channel parameter at the end, but it gets eaten by the reason + .inputContext = + { + .words = {"/ban", "forsen", "the", "ban", "reason", + "--channel", "xD"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason --channel xD", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // Normal ban with an added reason + .inputContext = + { + .words = {"/ban", "forsen", "the", "ban", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // Normal ban without an added reason + .inputContext = + { + .words = {"/ban", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User forgot to specify who to ban + .inputContext = + { + .words = {"/ban"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /ban outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/ban"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /ban without a target, but with a --channel specified + .inputContext = + { + .words = {"/ban", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the ban to be done in the pajlada channel + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "the", "ban", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, parseTimeoutActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/timeout"); + QString usage("usage string"); + bool withDuration = true; + bool withReason = true; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal timeout without an added reason, with the default duration + .inputContext = + { + .words = {"/timeout", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with a custom duration + .inputContext = + { + .words = {"/timeout", "forsen", "5m"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with a custom duration, with an added reason + .inputContext = + { + .words = {"/timeout", "forsen", "5m", "the", "timeout", + "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with an added reason, but user forgot to specify a timeout duration so it fails + .inputContext = + { + .words = {"/timeout", "forsen", "the", "timeout", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Invalid duration - " % usage, + }, + { + // User forgot to specify who to timeout + .inputContext = + { + .words = {"/timeout"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /timeout outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/timeout"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /timeout without a target, but with a --channel specified + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the timeout to be done in the pajlada channel + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a custom duration + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "5m"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "10m", "the", "timeout", + "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 10 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, parseUnbanActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/unban"); + QString usage("usage string"); + bool withDuration = false; + bool withReason = false; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal unban + .inputContext = + { + .words = {"/unban", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // Normal unban but user input some random junk after the target + .inputContext = + { + .words = {"/unban", "forsen", "foo", "bar", "baz"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // User forgot to specify who to unban + .inputContext = + { + .words = {"/unban"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /unban outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/unban"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /unban without a target, but with a --channel specified + .inputContext = + { + .words = {"/unban", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the unban to be done in the pajlada channel + .inputContext = + { + .words = {"/unban", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // User overriding the unban to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/unban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, E2E) +{ + ::testing::InSequence seq; + MockApplication app; + + app.commands.initialize(*getSettings(), getIApp()->getPaths()); + + QJsonObject pajlada; + pajlada["id"] = "11148817"; + pajlada["login"] = "pajlada"; + pajlada["display_name"] = "pajlada"; + pajlada["created_at"] = "2010-03-17T11:50:53Z"; + pajlada["description"] = " ͡° ͜ʖ ͡°)"; + pajlada["profile_image_url"] = + "https://static-cdn.jtvnw.net/jtv_user_pictures/" + "cbe986e3-06ad-4506-a3aa-eb05466c839c-profile_image-300x300.png"; + + QJsonObject testaccount420; + testaccount420["id"] = "117166826"; + testaccount420["login"] = "testaccount_420"; + testaccount420["display_name"] = "테스트계정420"; + testaccount420["created_at"] = "2016-02-27T18:55:59Z"; + testaccount420["description"] = ""; + testaccount420["profile_image_url"] = + "https://static-cdn.jtvnw.net/user-default-pictures-uv/" + "ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png"; + + QJsonObject forsen; + forsen["id"] = "22484632"; + forsen["login"] = "forsen"; + forsen["display_name"] = "Forsen"; + forsen["created_at"] = "2011-05-19T00:28:28Z"; + forsen["description"] = + "Approach with caution! No roleplaying or tryharding allowed."; + forsen["profile_image_url"] = + "https://static-cdn.jtvnw.net/jtv_user_pictures/" + "forsen-profile_image-48b43e1e4f54b5c8-300x300.png"; + + std::shared_ptr channel = + std::make_shared("pajlada"); + channel->setRoomId("11148817"); + + StrictMock mockHelix; + initializeHelix(&mockHelix); + + EXPECT_CALL(mockHelix, update).Times(1); + EXPECT_CALL(mockHelix, loadBlocks).Times(1); + + auto account = std::make_shared( + testaccount420["login"].toString(), "token", "oauthclient", + testaccount420["id"].toString()); + getIApp()->getAccounts()->twitch.accounts.append(account); + getIApp()->getAccounts()->twitch.currentUsername = + testaccount420["login"].toString(); + getIApp()->getAccounts()->twitch.load(); + + // Simple single-channel ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, + banUser(pajlada["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand("/ban forsen", channel, false); + + // Multi-channel ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{}, QString(""), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{}, QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel id:11148817 --channel testaccount_420 forsen", channel, + false); + + // ID-based ban + EXPECT_CALL(mockHelix, + banUser(pajlada["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand("/ban id:22484632", channel, false); + + // ID-based redirected ban + EXPECT_CALL(mockHelix, + banUser(testaccount420["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel id:117166826 id:22484632", channel, false); + + // name-based redirected ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"22484632"}, + QStringList{"testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + EXPECT_CALL(mockHelix, + banUser(testaccount420["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel testaccount_420 id:22484632", channel, false); + + // Multi-channel timeout + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{600}, QString(""), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{600}, QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/timeout --channel id:11148817 --channel testaccount_420 forsen", + channel, false); + + // Multi-channel unban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, unbanUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, unbanUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/unban --channel id:11148817 --channel testaccount_420 forsen", + channel, false); +} + +} // namespace chatterino