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:
+1
-1
@@ -504,7 +504,7 @@ PubSub *Application::getTwitchPubSub()
|
||||
return this->twitchPubSub.get();
|
||||
}
|
||||
|
||||
Logging *Application::getChatLogger()
|
||||
ILogging *Application::getChatLogger()
|
||||
{
|
||||
assertInGuiThread();
|
||||
|
||||
|
||||
+10
-2
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -463,6 +463,7 @@ private:
|
||||
friend class TwitchIrcServer;
|
||||
friend class TwitchMessageBuilder;
|
||||
friend class IrcMessageHandler;
|
||||
friend class Commands_E2E_Test;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user