mirror-chatterino2/src/controllers/commands/CommandController.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

695 lines
21 KiB
C++
Raw Normal View History

#include "controllers/commands/CommandController.hpp"
2018-06-26 14:09:39 +02:00
#include "Application.hpp"
#include "common/Channel.hpp"
2018-06-26 14:09:39 +02:00
#include "controllers/accounts/AccountController.hpp"
#include "controllers/commands/builtin/chatterino/Debugging.hpp"
#include "controllers/commands/builtin/Misc.hpp"
#include "controllers/commands/builtin/twitch/AddModerator.hpp"
#include "controllers/commands/builtin/twitch/AddVIP.hpp"
#include "controllers/commands/builtin/twitch/Announce.hpp"
#include "controllers/commands/builtin/twitch/Ban.hpp"
#include "controllers/commands/builtin/twitch/Block.hpp"
#include "controllers/commands/builtin/twitch/ChatSettings.hpp"
#include "controllers/commands/builtin/twitch/Chatters.hpp"
#include "controllers/commands/builtin/twitch/DeleteMessages.hpp"
#include "controllers/commands/builtin/twitch/GetModerators.hpp"
#include "controllers/commands/builtin/twitch/GetVIPs.hpp"
#include "controllers/commands/builtin/twitch/Raid.hpp"
#include "controllers/commands/builtin/twitch/RemoveModerator.hpp"
#include "controllers/commands/builtin/twitch/RemoveVIP.hpp"
#include "controllers/commands/builtin/twitch/SendReply.hpp"
#include "controllers/commands/builtin/twitch/SendWhisper.hpp"
#include "controllers/commands/builtin/twitch/ShieldMode.hpp"
#include "controllers/commands/builtin/twitch/Shoutout.hpp"
#include "controllers/commands/builtin/twitch/StartCommercial.hpp"
#include "controllers/commands/builtin/twitch/Unban.hpp"
#include "controllers/commands/builtin/twitch/UpdateChannel.hpp"
#include "controllers/commands/builtin/twitch/UpdateColor.hpp"
2018-06-26 14:09:39 +02:00
#include "controllers/commands/Command.hpp"
#include "controllers/commands/CommandContext.hpp"
2018-06-26 14:09:39 +02:00
#include "controllers/commands/CommandModel.hpp"
#include "controllers/plugins/PluginController.hpp"
#include "messages/Message.hpp"
2018-06-26 14:09:39 +02:00
#include "messages/MessageBuilder.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchCommon.hpp"
2018-10-26 17:38:17 +02:00
#include "singletons/Emotes.hpp"
2018-06-28 19:46:45 +02:00
#include "singletons/Paths.hpp"
2018-08-02 14:23:27 +02:00
#include "util/CombinePath.hpp"
#include "util/QStringHash.hpp"
#include "util/Qt.hpp"
#include <QString>
#include <unordered_map>
namespace {
using namespace chatterino;
using VariableReplacer = std::function<QString(
const QString &, const ChannelPtr &, const Message *)>;
const VariableReplacer NO_OP_PLACEHOLDER =
[](const auto &altText, const auto &channel, const auto *message) {
return altText;
};
const std::unordered_map<QString, VariableReplacer> COMMAND_VARS{
{
"channel.name",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(altText); //unused
(void)(message); //unused
return channel->getName();
},
},
{
"channel.id",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(message); //unused
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
return tc->roomId();
},
},
{
// NOTE: The use of {channel} is deprecated and support for it will drop at some point
// Users should be encouraged to use {channel.name} instead.
"channel",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(altText); //unused
(void)(message); //unused
return channel->getName();
},
},
{
"stream.game",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(message); //unused
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
const auto &status = tc->accessStreamStatus();
return status->live ? status->game : altText;
},
},
{
"stream.title",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(message); //unused
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
const auto &status = tc->accessStreamStatus();
return status->live ? status->title : altText;
},
},
{
"my.id",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
(void)(message); //unused
auto uid = getApp()->accounts->twitch.getCurrent()->getUserId();
return uid.isEmpty() ? altText : uid;
},
},
{
"my.name",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
(void)(message); //unused
auto name = getApp()->accounts->twitch.getCurrent()->getUserName();
return name.isEmpty() ? altText : name;
},
},
{
"user.name",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->loginName;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
{
// NOTE: The use of {user} is deprecated and support for it will drop at some point
// Users should be encouraged to use {user.name} instead.
"user",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->loginName;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
{
"msg.id",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->id;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
{
// NOTE: The use of {msg-id} is deprecated and support for it will drop at some point
// Users should be encouraged to use {msg.id} instead.
"msg-id",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->id;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
{
"msg.text",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->messageText;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
{
// NOTE: The use of {message} is deprecated and support for it will drop at some point
// Users should be encouraged to use {msg.text} instead.
"message",
[](const auto &altText, const auto &channel, const auto *message) {
(void)(channel); //unused
if (message == nullptr)
{
return altText;
}
const auto &v = message->messageText;
if (v.isEmpty())
{
return altText;
}
return v;
},
},
// variables used in mod buttons and the like, these make no sense in normal commands, so they are left empty
{"input.text", NO_OP_PLACEHOLDER},
};
2019-05-05 16:05:29 +02:00
} // namespace
namespace chatterino {
void CommandController::initialize(Settings &, Paths &paths)
{
// Update commands map when the vector of commands has been updated
auto addFirstMatchToMap = [this](auto args) {
this->userCommands_.remove(args.item.name);
for (const Command &cmd : this->items)
{
if (cmd.name == args.item.name)
{
this->userCommands_[cmd.name] = cmd;
break;
}
}
2018-11-03 14:52:38 +01:00
int maxSpaces = 0;
for (const Command &cmd : this->items)
2018-11-03 14:52:38 +01:00
{
auto localMaxSpaces = cmd.name.count(' ');
if (localMaxSpaces > maxSpaces)
{
maxSpaces = localMaxSpaces;
}
}
2018-11-03 14:52:38 +01:00
this->maxSpaces_ = maxSpaces;
};
// We can safely ignore these signal connections since items will be destroyed
// before CommandController
std::ignore = this->items.itemInserted.connect(addFirstMatchToMap);
std::ignore = this->items.itemRemoved.connect(addFirstMatchToMap);
// Initialize setting manager for commands.json
auto path = combinePath(paths.settingsDirectory, "commands.json");
this->sm_ = std::make_shared<pajlada::Settings::SettingManager>();
this->sm_->setPath(qPrintable(path));
this->sm_->setBackupEnabled(true);
this->sm_->setBackupSlots(9);
// Delayed initialization of the setting storing all commands
this->commandsSetting_.reset(
new pajlada::Settings::Setting<std::vector<Command>>("/commands",
this->sm_));
// Update the setting when the vector of commands has been updated (most
// likely from the settings dialog)
std::ignore = this->items.delayedItemsChanged.connect([this] {
this->commandsSetting_->setValue(this->items.raw());
});
// Load commands from commands.json
this->sm_->load();
// Add loaded commands to our vector of commands (which will update the map
// of commands)
for (const auto &command : this->commandsSetting_->getValue())
{
this->items.append(command);
}
/// Deprecated commands
this->registerCommand("/ignore", &commands::ignoreUser);
this->registerCommand("/unignore", &commands::unignoreUser);
this->registerCommand("/follow", &commands::follow);
this->registerCommand("/unfollow", &commands::unfollow);
/// Supported commands
this->registerCommand("/debug-args", &commands::listArgs);
this->registerCommand("/debug-env", &commands::listEnvironmentVariables);
this->registerCommand("/uptime", &commands::uptime);
this->registerCommand("/block", &commands::blockUser);
this->registerCommand("/unblock", &commands::unblockUser);
this->registerCommand("/user", &commands::user);
this->registerCommand("/usercard", &commands::openUsercard);
this->registerCommand("/requests", &commands::requests);
2021-03-14 18:25:45 +01:00
this->registerCommand("/lowtrust", &commands::lowtrust);
2021-03-14 18:25:45 +01:00
this->registerCommand("/chatters", &commands::chatters);
this->registerCommand("/test-chatters", &commands::testChatters);
2021-03-14 18:25:45 +01:00
this->registerCommand("/mods", &commands::getModerators);
this->registerCommand("/clip", &commands::clip);
this->registerCommand("/marker", &commands::marker);
this->registerCommand("/streamlink", &commands::streamlink);
this->registerCommand("/popout", &commands::popout);
this->registerCommand("/popup", &commands::popup);
this->registerCommand("/clearmessages", &commands::clearmessages);
this->registerCommand("/settitle", &commands::setTitle);
this->registerCommand("/setgame", &commands::setGame);
this->registerCommand("/openurl", &commands::openURL);
this->registerCommand("/raw", &commands::sendRawMessage);
this->registerCommand("/reply", &commands::sendReply);
#ifndef NDEBUG
this->registerCommand("/fakemsg", &commands::injectFakeMessage);
#endif
this->registerCommand("/copy", &commands::copyToClipboard);
this->registerCommand("/color", &commands::updateUserColor);
this->registerCommand("/clear", &commands::deleteAllMessages);
this->registerCommand("/delete", &commands::deleteOneMessage);
this->registerCommand("/mod", &commands::addModerator);
this->registerCommand("/unmod", &commands::removeModerator);
this->registerCommand("/announce", &commands::sendAnnouncement);
this->registerCommand("/vip", &commands::addVIP);
this->registerCommand("/unvip", &commands::removeVIP);
this->registerCommand("/unban", &commands::unbanUser);
this->registerCommand("/untimeout", &commands::unbanUser);
this->registerCommand("/raid", &commands::startRaid);
this->registerCommand("/unraid", &commands::cancelRaid);
this->registerCommand("/emoteonly", &commands::emoteOnly);
this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff);
this->registerCommand("/subscribers", &commands::subscribers);
this->registerCommand("/subscribersoff", &commands::subscribersOff);
this->registerCommand("/slow", &commands::slow);
this->registerCommand("/slowoff", &commands::slowOff);
this->registerCommand("/followers", &commands::followers);
this->registerCommand("/followersoff", &commands::followersOff);
this->registerCommand("/uniquechat", &commands::uniqueChat);
this->registerCommand("/r9kbeta", &commands::uniqueChat);
this->registerCommand("/uniquechatoff", &commands::uniqueChatOff);
this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff);
this->registerCommand("/timeout", &commands::sendTimeout);
this->registerCommand("/ban", &commands::sendBan);
this->registerCommand("/banid", &commands::sendBanById);
2022-10-08 13:11:55 +02:00
for (const auto &cmd : TWITCH_WHISPER_COMMANDS)
{
this->registerCommand(cmd, &commands::sendWhisper);
2022-10-08 13:11:55 +02:00
}
chore: migrate /vips command to Helix call (#4053) * feat(helix): create response model for VIP listing * feat(helix): stub out channel/vips request + handler * feat(helix): parse VIPs list from data and pass to callback * feat(helix): handle errors when getting VIP list then pass to callback * feat(command): add barebones handler for helix-based /vips * feat(command): provide better /vips output when user is not broadcaster * chore(format): bulk reformat with clang-format * chore(changelog): add entry for /vips Helix migration * fix(helix): use correct method when calling VIP list endpoint * fix(helix): use correct VIP list endpoint * chore(tidy): please clang-tidy by marking parameter as unused * feat(command): display unsorted VIP list returned from Helix API * feat(settings): clone raid timegate settings for /vips * feat(command): check /vips timegate setting before execution * feat(command): handle 0 VIPs from Helix response * feat(command): sort users alphabetically from Helix VIPs response * fix(command): highlight users in Helix /vips output to match IRC * fix(command): replace dynamic /vips error message with hardcoded string * chore(comment): remove TODO comment that was DONE * chore(format): bulk reformat using clang-format * fix(command): send 0 VIP message after creation * chore: apply suggestions from Felanbird * fix(helix): change mention of user ban to VIPs in VIP list error message * feat(helix): distinguish non-broadcaster auth error when getting VIPs * chore(command): move handling of non-broadcaster /vips usage to API response * chore(format): re-indent multiline string to get away from 80 char limit * reformat * fix tests Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
2022-10-10 12:56:55 +02:00
this->registerCommand("/vips", &commands::getVIPs);
this->registerCommand("/commercial", &commands::startCommercial);
this->registerCommand("/unstable-set-user-color",
&commands::unstableSetUserClientSideColor);
this->registerCommand("/debug-force-image-gc",
&commands::forceImageGarbageCollection);
this->registerCommand("/debug-force-image-unload",
&commands::forceImageUnload);
this->registerCommand("/shield", &commands::shieldModeOn);
this->registerCommand("/shieldoff", &commands::shieldModeOff);
this->registerCommand("/shoutout", &commands::sendShoutout);
this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules);
this->registerCommand("/c2-theme-autoreload", &commands::toggleThemeReload);
}
void CommandController::save()
{
this->sm_->save();
}
CommandModel *CommandController::createModel(QObject *parent)
{
CommandModel *model = new CommandModel(parent);
model->initialize(&this->items);
return model;
}
QString CommandController::execCommand(const QString &textNoEmoji,
ChannelPtr channel, bool dryRun)
{
QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji);
QStringList words = text.split(' ', Qt::SkipEmptyParts);
if (words.length() == 0)
{
return text;
}
QString commandName = words[0];
{
// check if user command exists
const auto it = this->userCommands_.find(commandName);
if (it != this->userCommands_.end())
{
text = getApp()->emotes->emojis.replaceShortCodes(
this->execCustomCommand(words, it.value(), dryRun, channel));
words = text.split(' ', Qt::SkipEmptyParts);
if (words.length() == 0)
{
return text;
}
commandName = words[0];
}
}
if (!dryRun)
2018-11-03 14:52:38 +01:00
{
// check if command exists
const auto it = this->commands_.find(commandName);
if (it != this->commands_.end())
{
if (auto *command = std::get_if<CommandFunction>(&it->second))
{
return (*command)(words, channel);
}
if (auto *command =
std::get_if<CommandFunctionWithContext>(&it->second))
{
CommandContext ctx{
words,
channel,
dynamic_cast<TwitchChannel *>(channel.get()),
};
return (*command)(ctx);
}
return "";
}
}
2023-02-19 20:19:18 +01:00
// We have checks to ensure words cannot be empty, so this can never wrap around
auto maxSpaces = std::min(this->maxSpaces_, (qsizetype)words.length() - 1);
2018-11-03 14:52:38 +01:00
for (int i = 0; i < maxSpaces; ++i)
{
commandName += ' ' + words[i + 1];
const auto it = this->userCommands_.find(commandName);
if (it != this->userCommands_.end())
{
return this->execCustomCommand(words, it.value(), dryRun, channel);
}
}
if (!dryRun && channel->getType() == Channel::Type::TwitchWhispers)
{
channel->addMessage(
makeSystemMessage("Use /w <username> <message> to whisper"));
return "";
}
2018-11-03 14:52:38 +01:00
return text;
}
#ifdef CHATTERINO_HAVE_PLUGINS
bool CommandController::registerPluginCommand(const QString &commandName)
{
if (this->commands_.contains(commandName))
{
return false;
}
this->commands_[commandName] = [commandName](const CommandContext &ctx) {
return getApp()->plugins->tryExecPluginCommand(commandName, ctx);
};
this->pluginCommands_.append(commandName);
return true;
}
bool CommandController::unregisterPluginCommand(const QString &commandName)
{
if (!this->pluginCommands_.contains(commandName))
{
return false;
}
this->pluginCommands_.removeAll(commandName);
return this->commands_.erase(commandName) != 0;
}
#endif
void CommandController::registerCommand(const QString &commandName,
CommandFunctionVariants commandFunction)
{
assert(this->commands_.count(commandName) == 0);
this->commands_[commandName] = std::move(commandFunction);
this->defaultChatterinoCommandAutoCompletions_.append(commandName);
}
QString CommandController::execCustomCommand(
2022-10-08 13:11:55 +02:00
const QStringList &words, const Command &command, bool /* dryRun */,
ChannelPtr channel, const Message *message,
std::unordered_map<QString, QString> context)
{
QString result;
static QRegularExpression parseCommand(
R"((^|[^{])({{)*{(\d+\+?|([a-zA-Z.-]+)(?:;(.+?))?)})");
int lastCaptureEnd = 0;
auto globalMatch = parseCommand.globalMatch(command.func);
int matchOffset = 0;
while (true)
{
QRegularExpressionMatch match =
parseCommand.match(command.func, matchOffset);
if (!match.hasMatch())
{
break;
}
result += command.func.mid(lastCaptureEnd,
match.capturedStart() - lastCaptureEnd + 1);
lastCaptureEnd = match.capturedEnd();
matchOffset = lastCaptureEnd - 1;
QString wordIndexMatch = match.captured(3);
bool plus = wordIndexMatch.at(wordIndexMatch.size() - 1) == '+';
wordIndexMatch = wordIndexMatch.replace("+", "");
bool ok;
int wordIndex = wordIndexMatch.replace("=", "").toInt(&ok);
if (!ok || wordIndex == 0)
{
auto varName = match.captured(4);
auto altText = match.captured(5); // alt text or empty string
auto var = context.find(varName);
if (var != context.end())
{
// Found variable in `context`
result += var->second.isEmpty() ? altText : var->second;
continue;
}
auto it = COMMAND_VARS.find(varName);
if (it != COMMAND_VARS.end())
{
// Found variable in `COMMAND_VARS`
result += it->second(altText, channel, message);
continue;
}
// Fall back to replacing it with the actual matched string
result += "{" + match.captured(3) + "}";
continue;
}
if (words.length() <= wordIndex)
{
continue;
}
if (plus)
{
bool first = true;
for (int i = wordIndex; i < words.length(); i++)
{
if (!first)
{
result += " ";
}
result += words[i];
first = false;
}
}
else
{
result += words[wordIndex];
}
}
result += command.func.mid(lastCaptureEnd);
if (result.size() > 0 && result.at(0) == '{')
{
result = result.mid(1);
}
2022-10-08 13:11:55 +02:00
return result.replace("{{", "{");
}
QStringList CommandController::getDefaultChatterinoCommandList()
{
return this->defaultChatterinoCommandAutoCompletions_;
}
} // namespace chatterino