#include "controllers/commands/CommandController.hpp" #include "Application.hpp" #include "common/Env.hpp" #include "common/NetworkResult.hpp" #include "common/QLogging.hpp" #include "common/SignalVector.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" #include "controllers/plugins/PluginController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" #include "messages/MessageThread.hpp" #include "providers/irc/IrcChannel2.hpp" #include "providers/irc/IrcServer.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/Clipboard.hpp" #include "util/CombinePath.hpp" #include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IncognitoBrowser.hpp" #include "util/Qt.hpp" #include "util/StreamerMode.hpp" #include "util/StreamLink.hpp" #include "util/Twitch.hpp" #include "widgets/dialogs/ReplyThreadPopup.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/splits/Split.hpp" #include "widgets/splits/SplitContainer.hpp" #include "widgets/Window.hpp" #include #include #include #include #include namespace { using namespace chatterino; bool areIRCCommandsStillAvailable() { // 11th of February 2023, 06:00am UTC const QDateTime migrationTime(QDate(2023, 2, 11), QTime(6, 0), Qt::UTC); auto now = QDateTime::currentDateTimeUtc(); return now < migrationTime; } QString useIRCCommand(const QStringList &words) { // Reform the original command auto originalCommand = words.join(" "); // Replace the / with a . to pass it along to TMI auto newCommand = originalCommand; newCommand.replace(0, 1, "."); qCDebug(chatterinoTwitch) << "Forwarding command" << originalCommand << "as" << newCommand; return newCommand; } void sendWhisperMessage(const QString &text) { // (hemirt) pajlada: "we should not be sending whispers through jtv, but // rather to your own username" auto app = getApp(); QString toSend = text.simplified(); app->twitch->sendMessage("jtv", toSend); } bool appendWhisperMessageWordsLocally(const QStringList &words) { auto app = getApp(); MessageBuilder b; b.emplace(); b.emplace(app->accounts->twitch.getCurrent()->getUserName(), MessageElementFlag::Text, MessageColor::Text, FontStyle::ChatMediumBold); b.emplace("->", MessageElementFlag::Text, getApp()->themes->messages.textColors.system); b.emplace(words[1] + ":", MessageElementFlag::Text, MessageColor::Text, FontStyle::ChatMediumBold); const auto &acc = app->accounts->twitch.getCurrent(); const auto &accemotes = *acc->accessEmotes(); const auto &bttvemotes = app->twitch->getBttvEmotes(); const auto &ffzemotes = app->twitch->getFfzEmotes(); auto flags = MessageElementFlags(); auto emote = boost::optional{}; for (int i = 2; i < words.length(); i++) { { // Twitch emote auto it = accemotes.emotes.find({words[i]}); if (it != accemotes.emotes.end()) { b.emplace(it->second, MessageElementFlag::TwitchEmote); continue; } } // Twitch emote { // bttv/ffz emote if ((emote = bttvemotes.emote({words[i]}))) { flags = MessageElementFlag::BttvEmote; } else if ((emote = ffzemotes.emote({words[i]}))) { flags = MessageElementFlag::FfzEmote; } if (emote) { b.emplace(emote.get(), flags); continue; } } // bttv/ffz emote { // emoji/text for (auto &variant : app->emotes->emojis.parse(words[i])) { constexpr const static struct { void operator()(EmotePtr emote, MessageBuilder &b) const { b.emplace(emote, MessageElementFlag::EmojiAll); } void operator()(const QString &string, MessageBuilder &b) const { auto linkString = b.matchLink(string); if (linkString.isEmpty()) { b.emplace(string, MessageElementFlag::Text); } else { b.addLink(string, linkString); } } } visitor; boost::apply_visitor( [&b](auto &&arg) { visitor(arg, b); }, variant); } // emoji/text } } b->flags.set(MessageFlag::DoNotTriggerNotification); b->flags.set(MessageFlag::Whisper); auto messagexD = b.release(); app->twitch->whispersChannel->addMessage(messagexD); auto overrideFlags = boost::optional(messagexD->flags); overrideFlags->set(MessageFlag::DoNotLog); if (getSettings()->inlineWhispers && !(getSettings()->streamerModeSuppressInlineWhispers && isInStreamerMode())) { app->twitch->forEachChannel( [&messagexD, overrideFlags](ChannelPtr _channel) { _channel->addMessage(messagexD, overrideFlags); }); } return true; } bool useIrcForWhisperCommand() { switch (getSettings()->helixTimegateWhisper.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return true; } // fall through to Helix logic } break; case HelixTimegateOverride::AlwaysUseIRC: { return true; } break; case HelixTimegateOverride::AlwaysUseHelix: { // do nothing and fall through to Helix logic } break; } return false; } QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel) { if (words.size() < 3) { channel->addMessage( makeSystemMessage("Usage: /w ")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to send a whisper!")); return ""; } auto target = words.at(1); stripChannelName(target); auto message = words.mid(2).join(' '); if (channel->isTwitchChannel()) { // this covers all twitch channels and twitch-like channels if (useIrcForWhisperCommand()) { appendWhisperMessageWordsLocally(words); sendWhisperMessage(words.join(' ')); return ""; } getHelix()->getUserByName( target, [channel, currentUser, target, message, words](const auto &targetUser) { getHelix()->sendWhisper( currentUser->getUserId(), targetUser.id, message, [words] { appendWhisperMessageWordsLocally(words); }, [channel, target, targetUser](auto error, auto message) { using Error = HelixWhisperError; QString errorMessage = "Failed to send whisper - "; switch (error) { case Error::NoVerifiedPhone: { errorMessage += "Due to Twitch restrictions, you are now " "required to have a verified phone number " "to send whispers. You can add a phone " "number in Twitch settings. " "https://www.twitch.tv/settings/security"; }; break; case Error::RecipientBlockedUser: { errorMessage += "The recipient doesn't allow whispers " "from strangers or you directly."; }; break; case Error::WhisperSelf: { errorMessage += "You cannot whisper yourself."; }; break; case Error::Forwarded: { errorMessage += message; } break; case Error::Ratelimited: { errorMessage += "You may only whisper a maximum of 40 " "unique recipients per day. Within the " "per day limit, you may whisper a " "maximum of 3 whispers per second and " "a maximum of 100 whispers per minute."; } break; case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel] { channel->addMessage( makeSystemMessage("No user matching that username.")); }); return ""; } // we must be on IRC auto *ircChannel = dynamic_cast(channel.get()); if (ircChannel == nullptr) { // give up return ""; } auto *server = ircChannel->server(); server->sendWhisper(target, message); return ""; } using VariableReplacer = std::function; const VariableReplacer NO_OP_PLACEHOLDER = [](const auto &altText, const auto &channel, const auto *message) { return altText; }; const std::unordered_map 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(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(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(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}, }; } // 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; } } int maxSpaces = 0; for (const Command &cmd : this->items) { auto localMaxSpaces = cmd.name.count(' '); if (localMaxSpaces > maxSpaces) { maxSpaces = localMaxSpaces; } } this->maxSpaces_ = maxSpaces; }; this->items.itemInserted.connect(addFirstMatchToMap); this->items.itemRemoved.connect(addFirstMatchToMap); // Initialize setting manager for commands.json auto path = combinePath(paths.settingsDirectory, "commands.json"); this->sm_ = std::make_shared(); 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>("/commands", this->sm_)); // Update the setting when the vector of commands has been updated (most // likely from the settings dialog) 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 auto blockLambda = [](const auto &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /block command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage("Usage: /block ")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to block someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [currentUser, channel, target](const HelixUser &targetUser) { getApp()->accounts->twitch.getCurrent()->blockUser( targetUser.id, [channel, target, targetUser] { channel->addMessage(makeSystemMessage( QString("You successfully blocked user %1") .arg(target))); }, [channel, target] { channel->addMessage(makeSystemMessage( QString("User %1 couldn't be blocked, an unknown " "error occurred!") .arg(target))); }); }, [channel, target] { channel->addMessage( makeSystemMessage(QString("User %1 couldn't be blocked, no " "user with that name found!") .arg(target))); }); return ""; }; auto unblockLambda = [](const auto &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /unblock command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage("Usage: /unblock ")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to unblock someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [currentUser, channel, target](const auto &targetUser) { getApp()->accounts->twitch.getCurrent()->unblockUser( targetUser.id, [channel, target, targetUser] { channel->addMessage(makeSystemMessage( QString("You successfully unblocked user %1") .arg(target))); }, [channel, target] { channel->addMessage(makeSystemMessage( QString("User %1 couldn't be unblocked, an unknown " "error occurred!") .arg(target))); }); }, [channel, target] { channel->addMessage( makeSystemMessage(QString("User %1 couldn't be unblocked, " "no user with that name found!") .arg(target))); }); return ""; }; this->registerCommand( "/ignore", [blockLambda](const auto &words, auto channel) { channel->addMessage(makeSystemMessage( "Ignore command has been renamed to /block, please use it from " "now on as /ignore is going to be removed soon.")); blockLambda(words, channel); return ""; }); this->registerCommand( "/unignore", [unblockLambda](const auto &words, auto channel) { channel->addMessage(makeSystemMessage( "Unignore command has been renamed to /unblock, please use it " "from now on as /unignore is going to be removed soon.")); unblockLambda(words, channel); return ""; }); this->registerCommand("/follow", [](const auto &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { return ""; } channel->addMessage(makeSystemMessage( "Twitch has removed the ability to follow users through " "third-party applications. For more information, see " "https://github.com/Chatterino/chatterino2/issues/3076")); return ""; }); this->registerCommand("/unfollow", [](const auto &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { return ""; } channel->addMessage(makeSystemMessage( "Twitch has removed the ability to unfollow users through " "third-party applications. For more information, see " "https://github.com/Chatterino/chatterino2/issues/3076")); return ""; }); /// Supported commands this->registerCommand( "/debug-args", [](const auto & /*words*/, auto channel) { QString msg = QApplication::instance()->arguments().join(' '); channel->addMessage(makeSystemMessage(msg)); return ""; }); this->registerCommand("/debug-env", [](const auto & /*words*/, ChannelPtr channel) { auto env = Env::get(); QStringList debugMessages{ "recentMessagesApiUrl: " + env.recentMessagesApiUrl, "linkResolverUrl: " + env.linkResolverUrl, "twitchServerHost: " + env.twitchServerHost, "twitchServerPort: " + QString::number(env.twitchServerPort), "twitchServerSecure: " + QString::number(env.twitchServerSecure), }; for (QString &str : debugMessages) { MessageBuilder builder; builder.emplace(QTime::currentTime()); builder.emplace(str, MessageElementFlag::Text, MessageColor::System); channel->addMessage(builder.release()); } return ""; }); this->registerCommand("/uptime", [](const auto & /*words*/, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /uptime command only works in Twitch Channels")); return ""; } const auto &streamStatus = twitchChannel->accessStreamStatus(); QString messageText = streamStatus->live ? streamStatus->uptime : "Channel is not live."; channel->addMessage(makeSystemMessage(messageText)); return ""; }); this->registerCommand("/block", blockLambda); this->registerCommand("/unblock", unblockLambda); this->registerCommand("/user", [](const auto &words, auto channel) { if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /user [channel]")); return ""; } QString userName = words[1]; stripUserName(userName); QString channelName = channel->getName(); if (words.size() > 2) { channelName = words[2]; stripChannelName(channelName); } openTwitchUsercard(channelName, userName); return ""; }); this->registerCommand("/usercard", [](const auto &words, auto channel) { if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /usercard [channel]")); return ""; } QString userName = words[1]; stripUserName(userName); if (words.size() > 2) { QString channelName = words[2]; stripChannelName(channelName); ChannelPtr channelTemp = getApp()->twitch->getChannelOrEmpty(channelName); if (channelTemp->isEmpty()) { channel->addMessage(makeSystemMessage( "A usercard can only be displayed for a channel that is " "currently opened in Chatterino.")); return ""; } channel = channelTemp; } // try to link to current split if possible Split *currentSplit = nullptr; auto *currentPage = dynamic_cast( getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); if (currentPage != nullptr) { currentSplit = currentPage->getSelectedSplit(); } auto differentChannel = currentSplit != nullptr && currentSplit->getChannel() != channel; if (differentChannel || currentSplit == nullptr) { // not possible to use current split, try searching for one const auto ¬ebook = getApp()->windows->getMainWindow().getNotebook(); auto count = notebook.getPageCount(); for (int i = 0; i < count; i++) { auto *page = notebook.getPageAt(i); auto *container = dynamic_cast(page); assert(container != nullptr); for (auto *split : container->getSplits()) { if (split->getChannel() == channel) { currentSplit = split; break; } } } // This would have crashed either way. assert(currentSplit != nullptr && "something went HORRIBLY wrong with the /usercard " "command. It couldn't find a split for a channel which " "should be open."); } auto *userPopup = new UserInfoPopup( getSettings()->autoCloseUserPopup, static_cast(&(getApp()->windows->getMainWindow())), currentSplit); userPopup->setData(userName, channel); userPopup->move(QCursor::pos()); userPopup->show(); return ""; }); this->registerCommand("/requests", [](const QStringList &words, ChannelPtr channel) { QString target(words.value(1)); if (target.isEmpty()) { if (channel->getType() == Channel::Type::Twitch && !channel->isEmpty()) { target = channel->getName(); } else { channel->addMessage(makeSystemMessage( "Usage: /requests [channel]. You can also use the command " "without arguments in any Twitch channel to open its " "channel points requests queue. Only the broadcaster and " "moderators have permission to view the queue.")); return ""; } } stripChannelName(target); QDesktopServices::openUrl( QUrl(QString("https://www.twitch.tv/popout/%1/reward-queue") .arg(target))); return ""; }); this->registerCommand("/lowtrust", [](const QStringList &words, ChannelPtr channel) { QString target(words.value(1)); if (target.isEmpty()) { if (channel->getType() == Channel::Type::Twitch && !channel->isEmpty()) { target = channel->getName(); } else { channel->addMessage(makeSystemMessage( "Usage: /lowtrust [channel]. You can also use the command " "without arguments in any Twitch channel to open its " "suspicious user activity feed. Only the broadcaster and " "moderators have permission to view this feed.")); return ""; } } stripChannelName(target); QDesktopServices::openUrl(QUrl( QString("https://www.twitch.tv/popout/moderator/%1/low-trust-users") .arg(target))); return ""; }); auto formatChattersError = [](HelixGetChattersError error, QString message) { using Error = HelixGetChattersError; QString errorMessage = QString("Failed to get chatter count - "); switch (error) { case Error::Forwarded: { errorMessage += message; } break; case Error::UserMissingScope: { errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { errorMessage += "You must have moderator permissions to " "use this command."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } return errorMessage; }; this->registerCommand( "/chatters", [formatChattersError](const auto &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /chatters command only works in Twitch Channels")); return ""; } // Refresh chatter list via helix api for mods getHelix()->getChatters( twitchChannel->roomId(), getApp()->accounts->twitch.getCurrent()->getUserId(), 1, [channel](auto result) { channel->addMessage(makeSystemMessage( QString("Chatter count: %1") .arg(localizeNumbers(result.total)))); }, [channel, formatChattersError](auto error, auto message) { auto errorMessage = formatChattersError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand("/test-chatters", [formatChattersError]( const auto & /*words*/, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /test-chatters command only works in Twitch Channels")); return ""; } getHelix()->getChatters( twitchChannel->roomId(), getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, [channel, twitchChannel](auto result) { QStringList entries; for (const auto &username : result.chatters) { entries << username; } QString prefix = "Chatters "; if (result.total > 5000) { prefix += QString("(5000/%1):").arg(result.total); } else { prefix += QString("(%1):").arg(result.total); } MessageBuilder builder; TwitchMessageBuilder::listOfUsersSystemMessage( prefix, entries, twitchChannel, &builder); channel->addMessage(builder.release()); }, [channel, formatChattersError](auto error, auto message) { auto errorMessage = formatChattersError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); auto formatModsError = [](HelixGetModeratorsError error, QString message) { using Error = HelixGetModeratorsError; QString errorMessage = QString("Failed to get moderators - "); switch (error) { case Error::Forwarded: { errorMessage += message; } break; case Error::UserMissingScope: { errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { errorMessage += "Due to Twitch restrictions, " "this command can only be used by the broadcaster. " "To see the list of mods you must use the Twitch website."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } return errorMessage; }; this->registerCommand( "/mods", [formatModsError](const QStringList &words, auto channel) -> QString { auto twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /mods command only works in Twitch Channels")); return ""; } switch (getSettings()->helixTimegateModerators.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return useIRCCommand(words); } } break; case HelixTimegateOverride::AlwaysUseIRC: { return useIRCCommand(words); } break; case HelixTimegateOverride::AlwaysUseHelix: { // Fall through to helix logic } break; } getHelix()->getModerators( twitchChannel->roomId(), 500, [channel, twitchChannel](auto result) { if (result.empty()) { channel->addMessage(makeSystemMessage( "This channel does not have any moderators.")); return; } // TODO: sort results? MessageBuilder builder; TwitchMessageBuilder::listOfUsersSystemMessage( "The moderators of this channel are", result, twitchChannel, &builder); channel->addMessage(builder.release()); }, [channel, formatModsError](auto error, auto message) { auto errorMessage = formatModsError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { if (const auto type = channel->getType(); type != Channel::Type::Twitch && type != Channel::Type::TwitchWatching) { channel->addMessage(makeSystemMessage( "The /clip command only works in Twitch Channels")); return ""; } auto *twitchChannel = dynamic_cast(channel.get()); twitchChannel->createClip(); return ""; }); this->registerCommand("/marker", [](const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /marker command only works in Twitch channels")); return ""; } // Avoid Helix calls without Client ID and/or OAuth Token if (getApp()->accounts->twitch.getCurrent()->isAnon()) { channel->addMessage(makeSystemMessage( "You need to be logged in to create stream markers!")); return ""; } // Exact same message as in webchat if (!twitchChannel->isLive()) { channel->addMessage(makeSystemMessage( "You can only add stream markers during live streams. Try " "again when the channel is live streaming.")); return ""; } auto arguments = words; arguments.removeFirst(); getHelix()->createStreamMarker( // Limit for description is 140 characters, webchat just crops description // if it's >140 characters, so we're doing the same thing twitchChannel->roomId(), arguments.join(" ").left(140), [channel, arguments](const HelixStreamMarker &streamMarker) { channel->addMessage(makeSystemMessage( QString("Successfully added a stream marker at %1%2") .arg(formatTime(streamMarker.positionSeconds)) .arg(streamMarker.description.isEmpty() ? "" : QString(": \"%1\"") .arg(streamMarker.description)))); }, [channel](auto error) { QString errorMessage("Failed to create stream marker - "); switch (error) { case HelixStreamMarkerError::UserNotAuthorized: { errorMessage += "you don't have permission to perform that action."; } break; case HelixStreamMarkerError::UserNotAuthenticated: { errorMessage += "you need to re-authenticate."; } break; // This would most likely happen if the service is down, or if the JSON payload returned has changed format case HelixStreamMarkerError::Unknown: default: { errorMessage += "an unknown error occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand("/streamlink", [](const QStringList &words, ChannelPtr channel) { QString target(words.value(1)); if (target.isEmpty()) { if (channel->getType() == Channel::Type::Twitch && !channel->isEmpty()) { target = channel->getName(); } else { channel->addMessage(makeSystemMessage( "/streamlink [channel]. Open specified Twitch channel in " "streamlink. If no channel argument is specified, open the " "current Twitch channel instead.")); return ""; } } stripChannelName(target); openStreamlinkForChannel(target); return ""; }); this->registerCommand("/popout", [](const QStringList &words, ChannelPtr channel) { QString target(words.value(1)); if (target.isEmpty()) { if (channel->getType() == Channel::Type::Twitch && !channel->isEmpty()) { target = channel->getName(); } else { channel->addMessage(makeSystemMessage( "Usage: /popout . You can also use the command " "without arguments in any Twitch channel to open its " "popout chat.")); return ""; } } stripChannelName(target); QDesktopServices::openUrl( QUrl(QString("https://www.twitch.tv/popout/%1/chat?popout=") .arg(target))); return ""; }); this->registerCommand("/popup", [](const QStringList &words, ChannelPtr sourceChannel) { static const auto *usageMessage = "Usage: /popup [channel]. Open specified Twitch channel in " "a new window. If no channel argument is specified, open " "the currently selected split instead."; QString target(words.value(1)); stripChannelName(target); // Popup the current split if (target.isEmpty()) { auto *currentPage = dynamic_cast(getApp() ->windows->getMainWindow() .getNotebook() .getSelectedPage()); if (currentPage != nullptr) { auto *currentSplit = currentPage->getSelectedSplit(); if (currentSplit != nullptr) { currentSplit->popup(); return ""; } } sourceChannel->addMessage(makeSystemMessage(usageMessage)); return ""; } // Open channel passed as argument in a popup auto *app = getApp(); auto targetChannel = app->twitch->getOrAddChannel(target); app->windows->openInPopup(targetChannel); return ""; }); this->registerCommand("/clearmessages", [](const auto & /*words*/, ChannelPtr channel) { auto *currentPage = dynamic_cast( getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); if (auto split = currentPage->getSelectedSplit()) { split->getChannelView().clearMessages(); } return ""; }); this->registerCommand("/settitle", [](const QStringList &words, ChannelPtr channel) { if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /settitle ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) { auto status = twitchChannel->accessStreamStatus(); auto title = words.mid(1).join(" "); getHelix()->updateChannel( twitchChannel->roomId(), "", "", title, [channel, title](NetworkResult) { channel->addMessage(makeSystemMessage( QString("Updated title to %1").arg(title))); }, [channel] { channel->addMessage( makeSystemMessage("Title update failed! Are you " "missing the required scope?")); }); } else { channel->addMessage(makeSystemMessage( "Unable to set title of non-Twitch channel.")); } return ""; }); this->registerCommand("/setgame", [](const QStringList &words, const ChannelPtr channel) { if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /setgame ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) { const auto gameName = words.mid(1).join(" "); getHelix()->searchGames( gameName, [channel, twitchChannel, gameName](const std::vector &games) { if (games.empty()) { channel->addMessage( makeSystemMessage("Game not found.")); return; } auto matchedGame = games.at(0); if (games.size() > 1) { // NOTE: Improvements could be made with 'fuzzy string matching' code here // attempt to find the best looking game by comparing exactly with lowercase values for (const auto &game : games) { if (game.name.toLower() == gameName.toLower()) { matchedGame = game; break; } } } auto status = twitchChannel->accessStreamStatus(); getHelix()->updateChannel( twitchChannel->roomId(), matchedGame.id, "", "", [channel, games, matchedGame](const NetworkResult &) { channel->addMessage( makeSystemMessage(QString("Updated game to %1") .arg(matchedGame.name))); }, [channel] { channel->addMessage(makeSystemMessage( "Game update failed! Are you " "missing the required scope?")); }); }, [channel] { channel->addMessage( makeSystemMessage("Failed to look up game.")); }); } else { channel->addMessage( makeSystemMessage("Unable to set game of non-Twitch channel.")); } return ""; }); this->registerCommand("/openurl", [](const QStringList &words, const ChannelPtr channel) { if (words.size() < 2) { channel->addMessage(makeSystemMessage("Usage: /openurl ")); return ""; } QUrl url = QUrl::fromUserInput(words.mid(1).join(" ")); if (!url.isValid()) { channel->addMessage(makeSystemMessage("Invalid URL specified.")); return ""; } bool res = false; if (supportsIncognitoLinks() && getSettings()->openLinksIncognito) { res = openLinkIncognito(url.toString(QUrl::FullyEncoded)); } else { res = QDesktopServices::openUrl(url); } if (!res) { channel->addMessage(makeSystemMessage("Could not open URL.")); } return ""; }); this->registerCommand( "/raw", [](const QStringList &words, ChannelPtr channel) -> QString { if (channel->isTwitchChannel()) { getApp()->twitch->sendRawMessage(words.mid(1).join(" ")); } else { // other code down the road handles this for IRC return words.join(" "); } return ""; }); this->registerCommand( "/reply", [](const QStringList &words, ChannelPtr channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /reply command only works in Twitch channels")); return ""; } if (words.size() < 3) { channel->addMessage( makeSystemMessage("Usage: /reply ")); return ""; } QString username = words[1]; stripChannelName(username); auto snapshot = twitchChannel->getMessageSnapshot(); for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) { const auto &msg = *it; if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) { std::shared_ptr thread; // found most recent message by user if (msg->replyThread == nullptr) { thread = std::make_shared(msg); twitchChannel->addReplyThread(thread); } else { thread = msg->replyThread; } QString reply = words.mid(2).join(" "); twitchChannel->sendReply(reply, thread->rootId()); return ""; } } channel->addMessage( makeSystemMessage("A message from that user wasn't found")); return ""; }); #ifndef NDEBUG this->registerCommand( "/fakemsg", [](const QStringList &words, ChannelPtr channel) -> QString { if (!channel->isTwitchChannel()) { channel->addMessage(makeSystemMessage( "The /fakemsg command only works in Twitch channels.")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: /fakemsg (raw irc text) - injects raw irc text as " "if it was a message received from TMI")); return ""; } auto ircText = words.mid(1).join(" "); getApp()->twitch->addFakeMessage(ircText); return ""; }); #endif this->registerCommand( "/copy", [](const QStringList &words, ChannelPtr channel) -> QString { if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /copy - copies provided " "text to clipboard.")); return ""; } crossPlatformCopy(words.mid(1).join(" ")); return ""; }); this->registerCommand("/color", [](const QStringList &words, auto channel) { if (!channel->isTwitchChannel()) { channel->addMessage(makeSystemMessage( "The /color command only works in Twitch channels")); return ""; } auto user = getApp()->accounts->twitch.getCurrent(); // Avoid Helix calls without Client ID and/or OAuth Token if (user->isAnon()) { channel->addMessage(makeSystemMessage( "You must be logged in to use the /color command")); return ""; } auto colorString = words.value(1); if (colorString.isEmpty()) { channel->addMessage(makeSystemMessage( QString("Usage: /color - Color must be one of Twitch's " "supported colors (%1) or a hex code (#000000) if you " "have Turbo or Prime.") .arg(VALID_HELIX_COLORS.join(", ")))); return ""; } cleanHelixColorName(colorString); getHelix()->updateUserChatColor( user->getUserId(), colorString, [colorString, channel] { QString successMessage = QString("Your color has been changed to %1.") .arg(colorString); channel->addMessage(makeSystemMessage(successMessage)); }, [colorString, channel](auto error, auto message) { QString errorMessage = QString("Failed to change color to %1 - ").arg(colorString); switch (error) { case HelixUpdateUserChatColorError::UserMissingScope: { errorMessage += "Missing required scope. Re-login with your " "account and try again."; } break; case HelixUpdateUserChatColorError::InvalidColor: { errorMessage += QString("Color must be one of Twitch's " "supported colors (%1) or a " "hex code (#000000) if you " "have Turbo or Prime.") .arg(VALID_HELIX_COLORS.join(", ")); } break; case HelixUpdateUserChatColorError::Forwarded: { errorMessage += message + "."; } break; case HelixUpdateUserChatColorError::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); auto deleteMessages = [](TwitchChannel *twitchChannel, const QString &messageID) { const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; auto user = getApp()->accounts->twitch.getCurrent(); // Avoid Helix calls without Client ID and/or OAuth Token if (user->isAnon()) { twitchChannel->addMessage(makeSystemMessage( QString("You must be logged in to use the %1 command.") .arg(commandName))); return ""; } getHelix()->deleteChatMessages( twitchChannel->roomId(), user->getUserId(), messageID, []() { // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct // events to update state for us. }, [twitchChannel, messageID](auto error, auto message) { QString errorMessage = QString("Failed to delete chat messages - "); switch (error) { case HelixDeleteChatMessagesError::UserMissingScope: { errorMessage += "Missing required scope. Re-login with your " "account and try again."; } break; case HelixDeleteChatMessagesError::UserNotAuthorized: { errorMessage += "you don't have permission to perform that action."; } break; case HelixDeleteChatMessagesError::MessageUnavailable: { // Override default message prefix to match with IRC message format errorMessage = QString( "The message %1 does not exist, was deleted, " "or is too old to be deleted.") .arg(messageID); } break; case HelixDeleteChatMessagesError::UserNotAuthenticated: { errorMessage += "you need to re-authenticate."; } break; case HelixDeleteChatMessagesError::Forwarded: { errorMessage += message; } break; case HelixDeleteChatMessagesError::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } twitchChannel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }; this->registerCommand( "/clear", [deleteMessages](const QStringList &words, auto channel) { (void)words; // unused auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /clear command only works in Twitch channels")); return ""; } return deleteMessages(twitchChannel, QString()); }); this->registerCommand("/delete", [deleteMessages](const QStringList &words, auto channel) { // This is a wrapper over the Helix delete messages endpoint // We use this to ensure the user gets better error messages for missing or malformed arguments auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /delete command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage( makeSystemMessage("Usage: /delete - Deletes the " "specified message.")); return ""; } auto messageID = words.at(1); auto uuid = QUuid(messageID); if (uuid.isNull()) { // The message id must be a valid UUID channel->addMessage(makeSystemMessage( QString("Invalid msg-id: \"%1\"").arg(messageID))); return ""; } auto msg = channel->findMessage(messageID); if (msg != nullptr) { if (msg->loginName == channel->getName() && !channel->isBroadcaster()) { channel->addMessage(makeSystemMessage( "You cannot delete the broadcaster's messages unless " "you are the broadcaster.")); return ""; } } return deleteMessages(twitchChannel, messageID); }); this->registerCommand("/mod", [](const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /mod command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: \"/mod \" - Grant moderator status to a " "user. Use \"/mods\" to list the moderators of this channel.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to mod someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [twitchChannel, channel](const HelixUser &targetUser) { getHelix()->addChannelModerator( twitchChannel->roomId(), targetUser.id, [channel, targetUser] { channel->addMessage(makeSystemMessage( QString("You have added %1 as a moderator of this " "channel.") .arg(targetUser.displayName))); }, [channel, targetUser](auto error, auto message) { QString errorMessage = QString("Failed to add channel moderator - "); using Error = HelixAddChannelModeratorError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::TargetIsVIP: { errorMessage += QString("%1 is currently a VIP, \"/unvip\" " "them and " "retry this command.") .arg(targetUser.displayName); } break; case Error::TargetAlreadyModded: { // Equivalent irc error errorMessage = QString("%1 is already a moderator of this " "channel.") .arg(targetUser.displayName); } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }); this->registerCommand("/unmod", [](const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /unmod command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: \"/unmod \" - Revoke moderator status from a " "user. Use \"/mods\" to list the moderators of this channel.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to unmod someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [twitchChannel, channel](const HelixUser &targetUser) { getHelix()->removeChannelModerator( twitchChannel->roomId(), targetUser.id, [channel, targetUser] { channel->addMessage(makeSystemMessage( QString("You have removed %1 as a moderator of " "this channel.") .arg(targetUser.displayName))); }, [channel, targetUser](auto error, auto message) { QString errorMessage = QString("Failed to remove channel moderator - "); using Error = HelixRemoveChannelModeratorError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::TargetNotModded: { // Equivalent irc error errorMessage += QString("%1 is not a moderator of this " "channel.") .arg(targetUser.displayName); } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }); this->registerCommand( "/announce", [](const QStringList &words, auto channel) -> QString { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "This command can only be used in Twitch channels.")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: /announce - Call attention to your " "message with a highlight.")); return ""; } auto user = getApp()->accounts->twitch.getCurrent(); if (user->isAnon()) { channel->addMessage(makeSystemMessage( "You must be logged in to use the /announce command")); return ""; } getHelix()->sendChatAnnouncement( twitchChannel->roomId(), user->getUserId(), words.mid(1).join(" "), HelixAnnouncementColor::Primary, []() { // do nothing. }, [channel](auto error, auto message) { using Error = HelixSendChatAnnouncementError; QString errorMessage = QString("Failed to send announcement - "); switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. Re-login with your " "account and try again."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand("/vip", [](const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /vip command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: \"/vip \" - Grant VIP status to a user. Use " "\"/vips\" to list the VIPs of this channel.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to VIP someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [twitchChannel, channel](const HelixUser &targetUser) { getHelix()->addChannelVIP( twitchChannel->roomId(), targetUser.id, [channel, targetUser] { channel->addMessage(makeSystemMessage( QString( "You have added %1 as a VIP of this channel.") .arg(targetUser.displayName))); }, [channel, targetUser](auto error, auto message) { QString errorMessage = QString("Failed to add VIP - "); using Error = HelixAddChannelVIPError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::Forwarded: { // These are actually the IRC equivalents, so we can ditch the prefix errorMessage = message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }); this->registerCommand("/unvip", [](const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /unvip command only works in Twitch channels")); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: \"/unvip \" - Revoke VIP status from a user. " "Use \"/vips\" to list the VIPs of this channel.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to UnVIP someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [twitchChannel, channel](const HelixUser &targetUser) { getHelix()->removeChannelVIP( twitchChannel->roomId(), targetUser.id, [channel, targetUser] { channel->addMessage(makeSystemMessage( QString( "You have removed %1 as a VIP of this channel.") .arg(targetUser.displayName))); }, [channel, targetUser](auto error, auto message) { QString errorMessage = QString("Failed to remove VIP - "); using Error = HelixRemoveChannelVIPError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::Forwarded: { // These are actually the IRC equivalents, so we can ditch the prefix errorMessage = message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }); // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch auto unbanLambda = [](auto words, auto channel) { auto commandName = words.at(0).toLower(); auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( QString("The %1 command only works in Twitch channels") .arg(commandName))); return ""; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( QString("Usage: \"%1 \" - Removes a ban on a user.") .arg(commandName))); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to unban someone!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [channel, currentUser, twitchChannel, target](const auto &targetUser) { getHelix()->unbanUser( twitchChannel->roomId(), currentUser->getUserId(), targetUser.id, [] { // No response for unbans, they're emitted over pubsub/IRC instead }, [channel, target, targetUser](auto error, auto message) { using Error = HelixUnbanUserError; QString errorMessage = QString("Failed to unban user - "); switch (error) { case Error::ConflictingOperation: { errorMessage += "There was a conflicting ban operation on " "this user. Please try again."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::TargetNotBanned: { // Equivalent IRC error errorMessage = QString( "%1 is not banned from this channel.") .arg(targetUser.displayName); } break; case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }; // These changes are from the helix-command-migration/unban-untimeout branch this->registerCommand("/unban", [unbanLambda](const QStringList &words, auto channel) { return unbanLambda(words, channel); }); // These changes are from the helix-command-migration/unban-untimeout branch this->registerCommand("/untimeout", [unbanLambda](const QStringList &words, auto channel) { return unbanLambda(words, channel); }); // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch this->registerCommand( // /raid "/raid", [](const QStringList &words, auto channel) -> QString { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /raid command only works in Twitch channels")); return ""; } switch (getSettings()->helixTimegateRaid.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return useIRCCommand(words); } // fall through to Helix logic } break; case HelixTimegateOverride::AlwaysUseIRC: { return useIRCCommand(words); } break; case HelixTimegateOverride::AlwaysUseHelix: { // do nothing and fall through to Helix logic } break; } if (words.size() < 2) { channel->addMessage(makeSystemMessage( "Usage: \"/raid \" - Raid a user. " "Only the broadcaster can start a raid.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage(makeSystemMessage( "You must be logged in to start a raid!")); return ""; } auto target = words.at(1); stripChannelName(target); getHelix()->getUserByName( target, [twitchChannel, channel](const HelixUser &targetUser) { getHelix()->startRaid( twitchChannel->roomId(), targetUser.id, [channel, targetUser] { channel->addMessage(makeSystemMessage( QString("You started to raid %1.") .arg(targetUser.displayName))); }, [channel, targetUser](auto error, auto message) { QString errorMessage = QString("Failed to start a raid - "); using Error = HelixStartRaidError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { errorMessage += "You must be the broadcaster " "to start a raid."; } break; case Error::CantRaidYourself: { errorMessage += "A channel cannot raid itself."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited " "by Twitch. Try " "again in a few seconds."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage( makeSystemMessage(errorMessage)); }); }, [channel, target] { // Equivalent error from IRC channel->addMessage(makeSystemMessage( QString("Invalid username: %1").arg(target))); }); return ""; }); // /raid this->registerCommand( // /unraid "/unraid", [](const QStringList &words, auto channel) -> QString { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /unraid command only works in Twitch channels")); return ""; } switch (getSettings()->helixTimegateRaid.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return useIRCCommand(words); } // fall through to Helix logic } break; case HelixTimegateOverride::AlwaysUseIRC: { return useIRCCommand(words); } break; case HelixTimegateOverride::AlwaysUseHelix: { // do nothing and fall through to Helix logic } break; } if (words.size() != 1) { channel->addMessage(makeSystemMessage( "Usage: \"/unraid\" - Cancel the current raid. " "Only the broadcaster can cancel the raid.")); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage(makeSystemMessage( "You must be logged in to cancel the raid!")); return ""; } getHelix()->cancelRaid( twitchChannel->roomId(), [channel] { channel->addMessage( makeSystemMessage(QString("You cancelled the raid."))); }, [channel](auto error, auto message) { QString errorMessage = QString("Failed to cancel the raid - "); using Error = HelixCancelRaidError; switch (error) { case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { errorMessage += "You must be the broadcaster " "to cancel the raid."; } break; case Error::NoRaidPending: { errorMessage += "You don't have an active raid."; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += "An unknown error has occurred."; } break; } channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); // unraid 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); auto formatBanTimeoutError = [](const char *operation, HelixBanUserError error, const QString &message, const QString &userTarget) -> QString { using Error = HelixBanUserError; QString errorMessage = QString("Failed to %1 user - ").arg(operation); switch (error) { case Error::ConflictingOperation: { errorMessage += "There was a conflicting ban operation on " "this user. Please try again."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::TargetBanned: { // Equivalent IRC error errorMessage += QString("%1 is already banned in this channel.") .arg(userTarget); } break; case Error::CannotBanUser: { // We can't provide the identical error as in IRC, // because we don't have enough information about the user. // The messages from IRC are formatted like this: // "You cannot {op} moderator {mod} unless you are the owner of this channel." // "You cannot {op} the broadcaster." errorMessage += QString("You cannot %1 %2.").arg(operation, userTarget); } break; case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } return errorMessage; }; this->registerCommand("/timeout", [formatBanTimeoutError]( const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( QString("The /timeout command only works in Twitch channels"))); return ""; } const auto *usageStr = "Usage: \"/timeout [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 ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to timeout someone!")); return ""; } auto target = words.at(1); stripChannelName(target); int duration = 10 * 60; // 10min if (words.size() >= 3) { duration = (int)parseDurationToSeconds(words.at(2)); if (duration <= 0) { channel->addMessage(makeSystemMessage(usageStr)); return ""; } } auto reason = words.mid(3).join(' '); getHelix()->getUserByName( target, [channel, currentUser, twitchChannel, target, duration, reason, formatBanTimeoutError](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, formatBanTimeoutError]( 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))); }); return ""; }); this->registerCommand("/ban", [formatBanTimeoutError]( const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( QString("The /ban command only works in Twitch channels"))); return ""; } const auto *usageStr = "Usage: \"/ban [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 ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to ban someone!")); return ""; } auto target = words.at(1); stripChannelName(target); auto reason = words.mid(2).join(' '); getHelix()->getUserByName( target, [channel, currentUser, twitchChannel, target, reason, formatBanTimeoutError](const auto &targetUser) { getHelix()->banUser( twitchChannel->roomId(), currentUser->getUserId(), targetUser.id, boost::none, reason, [] { // No response for bans, they're emitted over pubsub/IRC instead }, [channel, target, targetUser, formatBanTimeoutError]( 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))); }); return ""; }); this->registerCommand("/banid", [formatBanTimeoutError]( const QStringList &words, auto channel) { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( QString("The /banid command only works in Twitch channels"))); return ""; } const auto *usageStr = "Usage: \"/banid [reason]\" - Permanently prevent a user " "from chatting via their user ID. Reason is optional and will be " "shown to the target user and other moderators."; if (words.size() < 2) { channel->addMessage(makeSystemMessage(usageStr)); return ""; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage( makeSystemMessage("You must be logged in to ban someone!")); return ""; } auto target = words.at(1); auto reason = words.mid(2).join(' '); getHelix()->banUser( twitchChannel->roomId(), currentUser->getUserId(), target, boost::none, reason, [] { // No response for bans, they're emitted over pubsub/IRC instead }, [channel, target, formatBanTimeoutError](auto error, auto message) { auto errorMessage = formatBanTimeoutError("ban", error, message, "#" + target); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { this->registerCommand(cmd, [](const QStringList &words, auto channel) { return runWhisperCommand(words, channel); }); } auto formatVIPListError = [](HelixListVIPsError error, const QString &message) -> QString { using Error = HelixListVIPsError; QString errorMessage = QString("Failed to list VIPs - "); switch (error) { case Error::Forwarded: { errorMessage += message; } break; case Error::Ratelimited: { errorMessage += "You are being ratelimited by Twitch. Try " "again in a few seconds."; } break; case Error::UserMissingScope: { // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE errorMessage += "Missing required scope. " "Re-login with your " "account and try again."; } break; case Error::UserNotAuthorized: { // TODO(pajlada): Phrase MISSING_PERMISSION errorMessage += "You don't have permission to " "perform that action."; } break; case Error::UserNotBroadcaster: { errorMessage += "Due to Twitch restrictions, " "this command can only be used by the broadcaster. " "To see the list of VIPs you must use the Twitch website."; } break; case Error::Unknown: { errorMessage += "An unknown error has occurred."; } break; } return errorMessage; }; auto formatStartCommercialError = [](HelixStartCommercialError error, const QString &message) -> QString { using Error = HelixStartCommercialError; QString errorMessage = "Failed to start commercial - "; switch (error) { case Error::UserMissingScope: { errorMessage += "Missing required scope. Re-login with your " "account and try again."; } break; case Error::TokenMustMatchBroadcaster: { errorMessage += "Only the broadcaster of the channel can run " "commercials."; } break; case Error::BroadcasterNotStreaming: { errorMessage += "You must be streaming live to run " "commercials."; } break; case Error::MissingLengthParameter: { errorMessage += "Command must include a desired commercial break " "length that is greater than zero."; } break; case Error::Ratelimited: { errorMessage += "You must wait until your cooldown period " "expires before you can run another " "commercial."; } break; case Error::Forwarded: { errorMessage += message; } break; case Error::Unknown: default: { errorMessage += QString("An unknown error has occurred (%1).").arg(message); } break; } return errorMessage; }; this->registerCommand( "/vips", [formatVIPListError](const QStringList &words, auto channel) -> QString { auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( "The /vips command only works in Twitch channels")); return ""; } switch (getSettings()->helixTimegateVIPs.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return useIRCCommand(words); } // fall through to Helix logic } break; case HelixTimegateOverride::AlwaysUseIRC: { return useIRCCommand(words); } break; case HelixTimegateOverride::AlwaysUseHelix: { // do nothing and fall through to Helix logic } break; } auto currentUser = getApp()->accounts->twitch.getCurrent(); if (currentUser->isAnon()) { channel->addMessage(makeSystemMessage( "Due to Twitch restrictions, " // "this command can only be used by the broadcaster. " "To see the list of VIPs you must use the " "Twitch website.")); return ""; } getHelix()->getChannelVIPs( twitchChannel->roomId(), [channel, twitchChannel](const std::vector &vipList) { if (vipList.empty()) { channel->addMessage(makeSystemMessage( "This channel does not have any VIPs.")); return; } auto messagePrefix = QString("The VIPs of this channel are"); // TODO: sort results? MessageBuilder builder; TwitchMessageBuilder::listOfUsersSystemMessage( messagePrefix, vipList, twitchChannel, &builder); channel->addMessage(builder.release()); }, [channel, formatVIPListError](auto error, auto message) { auto errorMessage = formatVIPListError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand( "/commercial", [formatStartCommercialError](const QStringList &words, auto channel) -> QString { auto *tc = dynamic_cast(channel.get()); if (tc == nullptr) { channel->addMessage(makeSystemMessage( "The /commercial command only works in Twitch channels")); return ""; } const auto *usageStr = "Usage: \"/commercial \" - Starts a " "commercial with the " "specified duration for the current " "channel. Valid length options " "are 30, 60, 90, 120, 150, and 180 seconds."; switch (getSettings()->helixTimegateCommercial.getValue()) { case HelixTimegateOverride::Timegate: { if (areIRCCommandsStillAvailable()) { return useIRCCommand(words); } // fall through to Helix logic } break; case HelixTimegateOverride::AlwaysUseIRC: { return useIRCCommand(words); } break; case HelixTimegateOverride::AlwaysUseHelix: { // do nothing and fall through to Helix logic } break; } if (words.size() < 2) { channel->addMessage(makeSystemMessage(usageStr)); return ""; } auto user = getApp()->accounts->twitch.getCurrent(); // Avoid Helix calls without Client ID and/or OAuth Token if (user->isAnon()) { channel->addMessage(makeSystemMessage( "You must be logged in to use the /commercial command")); return ""; } auto broadcasterID = tc->roomId(); auto length = words.at(1).toInt(); getHelix()->startCommercial( broadcasterID, length, [channel](auto response) { channel->addMessage(makeSystemMessage( QString("Starting %1 second long commercial break. " "Keep in mind you are still " "live and not all viewers will receive a " "commercial. " "You may run another commercial in %2 seconds.") .arg(response.length) .arg(response.retryAfter))); }, [channel, formatStartCommercialError](auto error, auto message) { auto errorMessage = formatStartCommercialError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; }); this->registerCommand("/unstable-set-user-color", [](const auto &ctx) { if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage( makeSystemMessage("The /unstable-set-user-color command only " "works in Twitch channels")); return ""; } if (ctx.words.size() < 2) { ctx.channel->addMessage( makeSystemMessage(QString("Usage: %1 [color]") .arg(ctx.words.at(0)))); return ""; } auto userID = ctx.words.at(1); auto color = ctx.words.value(2); getIApp()->getUserData()->setUserColor(userID, color); return ""; }); } 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) { // check if command exists const auto it = this->commands_.find(commandName); if (it != this->commands_.end()) { if (auto *command = std::get_if(&it->second)) { return (*command)(words, channel); } if (auto *command = std::get_if(&it->second)) { CommandContext ctx{ words, channel, dynamic_cast(channel.get()), }; return (*command)(ctx); } return ""; } } // 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); 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 to whisper")); return ""; } 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( const QStringList &words, const Command &command, bool /* dryRun */, ChannelPtr channel, const Message *message, std::unordered_map 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); } return result.replace("{{", "{"); } QStringList CommandController::getDefaultChatterinoCommandList() { return this->defaultChatterinoCommandAutoCompletions_; } } // namespace chatterino