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`
This commit is contained in:
pajlada
2024-06-16 12:22:51 +02:00
committed by GitHub
parent 86871eec5a
commit 9b31246502
23 changed files with 1732 additions and 174 deletions
+1 -1
View File
@@ -504,7 +504,7 @@ PubSub *Application::getTwitchPubSub()
return this->twitchPubSub.get();
}
Logging *Application::getChatLogger()
ILogging *Application::getChatLogger()
{
assertInGuiThread();
+10 -2
View File
@@ -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;
+5
View File
@@ -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")
+1
View File
@@ -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);
+2 -1
View File
@@ -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);
+182 -108
View File
@@ -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...] <username> [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 <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 <username> [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...] <username> [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 <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 <username> [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 "";
@@ -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 <username> - Removes a ban on a user. Options: --channel <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 <username>\" - 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 "";
@@ -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 <QCommandLineParser>
#include <QStringBuilder>
#include <algorithm>
#include <ostream>
#include <string>
#include <vector>
namespace chatterino::commands {
bool IncompleteHelixUser::hydrateFrom(const std::vector<HelixUser> &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<std::vector<PerformChannelAction>, 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<PerformChannelAction> 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
@@ -0,0 +1,59 @@
#pragma once
#include <nonstd/expected.hpp>
#include <QString>
#include <ostream>
#include <tuple>
#include <vector>
namespace chatterino {
struct CommandContext;
struct HelixUser;
} // namespace chatterino
namespace chatterino::commands {
struct IncompleteHelixUser {
QString id;
QString login;
QString displayName;
bool hydrateFrom(const std::vector<HelixUser> &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<std::vector<PerformChannelAction>, QString> parseChannelAction(
const CommandContext &ctx, const QString &command, const QString &usage,
bool withDuration, bool withReason);
} // namespace chatterino::commands
+10 -15
View File
@@ -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())
{
+1
View File
@@ -463,6 +463,7 @@ private:
friend class TwitchIrcServer;
friend class TwitchMessageBuilder;
friend class IrcMessageHandler;
friend class Commands_E2E_Test;
};
} // namespace chatterino
+11 -2
View File
@@ -16,13 +16,22 @@ struct Message;
using MessagePtr = std::shared_ptr<const Message>;
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;
+3
View File
@@ -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