From fcc5f4b3dffc2aa0467662a2ef995b9d0335f901 Mon Sep 17 00:00:00 2001 From: pajlada Date: Wed, 8 Nov 2023 21:42:06 +0100 Subject: [PATCH] feat: Allow id: prefix in /ban and /timeout (#4945) ban example: `/ban id:70948394`, equivalent to `/banid 70948394` timeout example: `/timeout id:70948394 10 xd` --- CHANGELOG.md | 1 + .../commands/builtin/twitch/Ban.cpp | 154 +++++++++++------- src/util/Twitch.cpp | 27 +++ src/util/Twitch.hpp | 10 ++ tests/src/UtilTwitch.cpp | 110 +++++++++++++ 5 files changed, 243 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1673fb09eba..36d824972e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) - Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) +- Minor: Allow running `/ban` and `/timeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945) - Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp index 8c438539e43..2ecec39b8d0 100644 --- a/src/controllers/commands/builtin/twitch/Ban.cpp +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -78,7 +78,42 @@ QString formatBanTimeoutError(const char *operation, HelixBanUserError error, break; } return errorMessage; -}; +} + +void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + const QString &reason, const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, std::nullopt, + reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +void timeoutUserByID(const ChannelPtr &channel, + const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + int duration, const QString &reason, + const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, duration, reason, + [] { + // No response for timeouts, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("timeout", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} } // namespace @@ -120,32 +155,41 @@ QString sendBan(const CommandContext &ctx) return ""; } - auto target = words.at(1); - stripChannelName(target); - + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); auto reason = words.mid(2).join(' '); - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, - reason](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, std::nullopt, reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "ban", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage( - makeSystemMessage(QString("Invalid username: %1").arg(target))); - }); + if (!targetUserID.isEmpty()) + { + banUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUserID, reason, targetUserID); + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), targetUserID, + std::nullopt, reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, targetUserID{targetUserID}](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, targetUserID); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + } + 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))); + }); + } return ""; } @@ -188,17 +232,8 @@ QString sendBanById(const CommandContext &ctx) auto target = words.at(1); auto reason = words.mid(2).join(' '); - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), target, std::nullopt, - reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, target](auto error, auto message) { - auto errorMessage = - formatBanTimeoutError("ban", error, message, "#" + target); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + banUserByID(channel, twitchChannel, currentUser->getUserId(), target, + reason, target); return ""; } @@ -242,8 +277,8 @@ QString sendTimeout(const CommandContext &ctx) return ""; } - auto target = words.at(1); - stripChannelName(target); + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); int duration = 10 * 60; // 10min if (words.size() >= 3) @@ -257,27 +292,28 @@ QString sendTimeout(const CommandContext &ctx) } auto reason = words.mid(3).join(' '); - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, duration, - reason](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, duration, reason, - [] { - // No response for timeouts, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "timeout", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage( - makeSystemMessage(QString("Invalid username: %1").arg(target))); - }); + 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))); + }); + } return ""; } diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp index 12e7519848a..fa21c8583c7 100644 --- a/src/util/Twitch.cpp +++ b/src/util/Twitch.cpp @@ -62,6 +62,33 @@ void stripChannelName(QString &channelName) } } +std::pair parseUserNameOrID(const QString &input) +{ + if (input.startsWith("id:")) + { + return { + {}, + input.mid(3), + }; + } + + QString userName = input; + + if (userName.startsWith('@') || userName.startsWith('#')) + { + userName.remove(0, 1); + } + if (userName.endsWith(',')) + { + userName.chop(1); + } + + return { + userName, + {}, + }; +} + QRegularExpression twitchUserNameRegexp() { static QRegularExpression re( diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp index c3bb346a9d0..367b6cc98c5 100644 --- a/src/util/Twitch.hpp +++ b/src/util/Twitch.hpp @@ -16,6 +16,16 @@ void stripUserName(QString &userName); // stripChannelName removes any @ prefix or , suffix to make it more suitable for command use void stripChannelName(QString &channelName); +using ParsedUserName = QString; +using ParsedUserID = QString; + +/** + * Parse the given input into either a user name or a user ID + * + * User IDs take priority and are parsed if the input starts with `id:` + */ +std::pair parseUserNameOrID(const QString &input); + // Matches a strict Twitch user login. // May contain lowercase a-z, 0-9, and underscores // Must contain between 1 and 25 characters diff --git a/tests/src/UtilTwitch.cpp b/tests/src/UtilTwitch.cpp index c02753ca306..6a0b58d9fa6 100644 --- a/tests/src/UtilTwitch.cpp +++ b/tests/src/UtilTwitch.cpp @@ -160,6 +160,116 @@ TEST(UtilTwitch, StripChannelName) } } +TEST(UtilTwitch, ParseUserNameOrID) +{ + struct TestCase { + QString input; + QString expectedUserName; + QString expectedUserID; + }; + + std::vector tests{ + { + "pajlada", + "pajlada", + {}, + }, + { + "Pajlada", + "Pajlada", + {}, + }, + { + "@Pajlada", + "Pajlada", + {}, + }, + { + "#Pajlada", + "Pajlada", + {}, + }, + { + "#Pajlada,", + "Pajlada", + {}, + }, + { + "#Pajlada,", + "Pajlada", + {}, + }, + { + "@@Pajlada,", + "@Pajlada", + {}, + }, + { + // We only strip one character off the front + "#@Pajlada,", + "@Pajlada", + {}, + }, + { + "@@Pajlada,,", + "@Pajlada,", + {}, + }, + { + "", + "", + {}, + }, + { + "@", + "", + {}, + }, + { + ",", + "", + {}, + }, + { + // We purposefully don't handle spaces at the end, as all expected usages of this function split the message up by space and strip the parameters by themselves + ", ", + ", ", + {}, + }, + { + // We purposefully don't handle spaces at the start, as all expected usages of this function split the message up by space and strip the parameters by themselves + " #", + " #", + {}, + }, + { + "id:123", + {}, + "123", + }, + { + "id:", + {}, + "", + }, + }; + + for (const auto &[input, expectedUserName, expectedUserID] : tests) + { + auto [actualUserName, actualUserID] = parseUserNameOrID(input); + + EXPECT_EQ(actualUserName, expectedUserName) + << "name " << qUtf8Printable(actualUserName) << " (" + << qUtf8Printable(input) << ") did not match expected value " + << qUtf8Printable(expectedUserName); + + EXPECT_EQ(actualUserID, expectedUserID) + << "id " << qUtf8Printable(actualUserID) << " (" + << qUtf8Printable(input) << ") did not match expected value " + << qUtf8Printable(expectedUserID); + } +} + TEST(UtilTwitch, UserLoginRegexp) { struct TestCase {