Skip to content

Commit

Permalink
feat: Allow id: prefix in /ban and /timeout (#4945)
Browse files Browse the repository at this point in the history
ban example: `/ban id:70948394`, equivalent to `/banid 70948394`
timeout example: `/timeout id:70948394 10 xd`
  • Loading branch information
pajlada authored Nov 8, 2023
1 parent 68817fa commit fcc5f4b
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 59 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
154 changes: 95 additions & 59 deletions src/controllers/commands/builtin/twitch/Ban.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 "";
}
Expand Down Expand Up @@ -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 "";
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 "";
}
Expand Down
27 changes: 27 additions & 0 deletions src/util/Twitch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ void stripChannelName(QString &channelName)
}
}

std::pair<ParsedUserName, ParsedUserID> 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(
Expand Down
10 changes: 10 additions & 0 deletions src/util/Twitch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParsedUserName, ParsedUserID> 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
Expand Down
110 changes: 110 additions & 0 deletions tests/src/UtilTwitch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,116 @@ TEST(UtilTwitch, StripChannelName)
}
}

TEST(UtilTwitch, ParseUserNameOrID)
{
struct TestCase {
QString input;
QString expectedUserName;
QString expectedUserID;
};

std::vector<TestCase> 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 {
Expand Down

0 comments on commit fcc5f4b

Please sign in to comment.