diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 84a6df64a..d1754239a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -59,14 +59,50 @@ set(SOURCE_FILES controllers/commands/builtin/chatterino/Debugging.cpp controllers/commands/builtin/chatterino/Debugging.hpp + controllers/commands/builtin/Misc.cpp + controllers/commands/builtin/Misc.hpp + controllers/commands/builtin/twitch/AddModerator.cpp + controllers/commands/builtin/twitch/AddModerator.hpp + controllers/commands/builtin/twitch/AddVIP.cpp + controllers/commands/builtin/twitch/AddVIP.hpp + controllers/commands/builtin/twitch/Announce.cpp + controllers/commands/builtin/twitch/Announce.hpp + controllers/commands/builtin/twitch/Ban.cpp + controllers/commands/builtin/twitch/Ban.hpp + controllers/commands/builtin/twitch/Block.cpp + controllers/commands/builtin/twitch/Block.hpp controllers/commands/builtin/twitch/ChatSettings.cpp controllers/commands/builtin/twitch/ChatSettings.hpp + controllers/commands/builtin/twitch/Chatters.cpp + controllers/commands/builtin/twitch/Chatters.hpp + controllers/commands/builtin/twitch/DeleteMessages.cpp + controllers/commands/builtin/twitch/DeleteMessages.hpp + controllers/commands/builtin/twitch/GetModerators.cpp + controllers/commands/builtin/twitch/GetModerators.hpp + controllers/commands/builtin/twitch/GetVIPs.cpp + controllers/commands/builtin/twitch/GetVIPs.hpp + controllers/commands/builtin/twitch/Raid.cpp + controllers/commands/builtin/twitch/Raid.hpp + controllers/commands/builtin/twitch/RemoveModerator.cpp + controllers/commands/builtin/twitch/RemoveModerator.hpp + controllers/commands/builtin/twitch/RemoveVIP.cpp + controllers/commands/builtin/twitch/RemoveVIP.hpp + controllers/commands/builtin/twitch/SendReply.cpp + controllers/commands/builtin/twitch/SendReply.hpp + controllers/commands/builtin/twitch/SendWhisper.cpp + controllers/commands/builtin/twitch/SendWhisper.hpp controllers/commands/builtin/twitch/ShieldMode.cpp controllers/commands/builtin/twitch/ShieldMode.hpp controllers/commands/builtin/twitch/Shoutout.cpp controllers/commands/builtin/twitch/Shoutout.hpp - controllers/commands/builtin/twitch/Ban.cpp - controllers/commands/builtin/twitch/Ban.hpp + controllers/commands/builtin/twitch/StartCommercial.cpp + controllers/commands/builtin/twitch/StartCommercial.hpp + controllers/commands/builtin/twitch/Unban.cpp + controllers/commands/builtin/twitch/Unban.hpp + controllers/commands/builtin/twitch/UpdateChannel.cpp + controllers/commands/builtin/twitch/UpdateChannel.hpp + controllers/commands/builtin/twitch/UpdateColor.cpp + controllers/commands/builtin/twitch/UpdateColor.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index de04141cb..9d66afd47 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1,353 +1,54 @@ #include "controllers/commands/CommandController.hpp" #include "Application.hpp" -#include "common/Env.hpp" -#include "common/LinkParser.hpp" -#include "common/NetworkResult.hpp" -#include "common/QLogging.hpp" -#include "common/SignalVector.hpp" +#include "common/Channel.hpp" #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" #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/Image.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/PostToThread.hpp" +#include "util/QStringHash.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 +#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 = std::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, 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 - { - LinkParser parser(string); - if (parser.result()) - { - b.addLink(*parser.result()); - } - else - { - b.emplace(string, - MessageElementFlag::Text); - } - } - } 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 = std::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; @@ -623,2019 +324,90 @@ void CommandController::initialize(Settings &, Paths &paths) /// 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 ""; - } + this->registerCommand("/ignore", &commands::ignoreUser); - auto currentUser = getApp()->accounts->twitch.getCurrent(); + this->registerCommand("/unignore", &commands::unignoreUser); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to block someone!")); - return ""; - } + this->registerCommand("/follow", &commands::follow); - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const HelixUser &targetUser) { - getApp()->accounts->twitch.getCurrent()->blockUser( - targetUser.id, nullptr, - [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, nullptr, - [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 ""; - }); + this->registerCommand("/unfollow", &commands::unfollow); /// Supported commands - this->registerCommand( - "/debug-args", [](const auto & /*words*/, auto channel) { - QString msg = QApplication::instance()->arguments().join(' '); + this->registerCommand("/debug-args", &commands::listArgs); - channel->addMessage(makeSystemMessage(msg)); + this->registerCommand("/debug-env", &commands::listEnvironmentVariables); - return ""; - }); + this->registerCommand("/uptime", &commands::uptime); - this->registerCommand("/debug-env", [](const auto & /*words*/, - ChannelPtr channel) { - auto env = Env::get(); + this->registerCommand("/block", &commands::blockUser); - QStringList debugMessages{ - "recentMessagesApiUrl: " + env.recentMessagesApiUrl, - "linkResolverUrl: " + env.linkResolverUrl, - "twitchServerHost: " + env.twitchServerHost, - "twitchServerPort: " + QString::number(env.twitchServerPort), - "twitchServerSecure: " + QString::number(env.twitchServerSecure), - }; + this->registerCommand("/unblock", &commands::unblockUser); - for (QString &str : debugMessages) - { - MessageBuilder builder; - builder.emplace(QTime::currentTime()); - builder.emplace(str, MessageElementFlag::Text, - MessageColor::System); - channel->addMessage(builder.release()); - } - return ""; - }); + this->registerCommand("/user", &commands::user); - 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 ""; - } + this->registerCommand("/usercard", &commands::openUsercard); - const auto &streamStatus = twitchChannel->accessStreamStatus(); + this->registerCommand("/requests", &commands::requests); - QString messageText = - streamStatus->live ? streamStatus->uptime : "Channel is not live."; + this->registerCommand("/lowtrust", &commands::lowtrust); - channel->addMessage(makeSystemMessage(messageText)); + this->registerCommand("/chatters", &commands::chatters); - return ""; - }); + this->registerCommand("/test-chatters", &commands::testChatters); - this->registerCommand("/block", blockLambda); + this->registerCommand("/mods", &commands::getModerators); - this->registerCommand("/unblock", unblockLambda); + this->registerCommand("/clip", &commands::clip); - 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); + this->registerCommand("/marker", &commands::marker); - QString channelName = channel->getName(); + this->registerCommand("/streamlink", &commands::streamlink); - if (words.size() > 2) - { - channelName = words[2]; - stripChannelName(channelName); - } - openTwitchUsercard(channelName, userName); + this->registerCommand("/popout", &commands::popout); - return ""; - }); + this->registerCommand("/popup", &commands::popup); - this->registerCommand("/usercard", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /usercard [channel] or " - "/usercard id: [channel]")); - return ""; - } + this->registerCommand("/clearmessages", &commands::clearmessages); - QString userName = words[1]; - stripUserName(userName); + this->registerCommand("/settitle", &commands::setTitle); - if (words.size() > 2) - { - QString channelName = words[2]; - stripChannelName(channelName); + this->registerCommand("/setgame", &commands::setGame); - ChannelPtr channelTemp = - getApp()->twitch->getChannelOrEmpty(channelName); + this->registerCommand("/openurl", &commands::openURL); - if (channelTemp->isEmpty()) - { - channel->addMessage(makeSystemMessage( - "A usercard can only be displayed for a channel that is " - "currently opened in Chatterino.")); - return ""; - } + this->registerCommand("/raw", &commands::sendRawMessage); - 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->moveTo(QCursor::pos(), - widgets::BoundsChecking::CursorPosition); - 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) - { - // found most recent message by user - if (msg->replyThread == nullptr) - { - // prepare thread if one does not exist - auto thread = std::make_shared(msg); - twitchChannel->addReplyThread(thread); - } - - QString reply = words.mid(2).join(" "); - twitchChannel->sendReply(reply, msg->id); - return ""; - } - } - - channel->addMessage( - makeSystemMessage("A message from that user wasn't found")); - - return ""; - }); + this->registerCommand("/reply", &commands::sendReply); #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 ""; - }); + this->registerCommand("/fakemsg", &commands::injectFakeMessage); #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("/copy", &commands::copyToClipboard); - 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(); + this->registerCommand("/color", &commands::updateUserColor); - // 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 ""; - } + this->registerCommand("/clear", &commands::deleteAllMessages); - auto colorString = words.value(1); + this->registerCommand("/delete", &commands::deleteOneMessage); - 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 ""; - } + this->registerCommand("/mod", &commands::addModerator); - cleanHelixColorName(colorString); + this->registerCommand("/unmod", &commands::removeModerator); - 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); + this->registerCommand("/announce", &commands::sendAnnouncement); - switch (error) - { - case HelixUpdateUserChatColorError::UserMissingScope: { - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; + this->registerCommand("/vip", &commands::addVIP); - 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; + this->registerCommand("/unvip", &commands::removeVIP); - case HelixUpdateUserChatColorError::Forwarded: { - errorMessage += message + "."; - } - break; + this->registerCommand("/unban", &commands::unbanUser); + this->registerCommand("/untimeout", &commands::unbanUser); - case HelixUpdateUserChatColorError::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } + this->registerCommand("/raid", &commands::startRaid); - 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 ""; - }); - - 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 ""; - }; - - this->registerCommand( - "/unban", [unbanLambda](const QStringList &words, auto channel) { - return unbanLambda(words, channel); - }); - - this->registerCommand( - "/untimeout", [unbanLambda](const QStringList &words, auto channel) { - return unbanLambda(words, channel); - }); - - 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("/unraid", &commands::cancelRaid); this->registerCommand("/emoteonly", &commands::emoteOnly); this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); @@ -2661,318 +433,21 @@ void CommandController::initialize(Settings &, Paths &paths) for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { - this->registerCommand(cmd, [](const QStringList &words, auto channel) { - return runWhisperCommand(words, channel); - }); + this->registerCommand(cmd, &commands::sendWhisper); } - auto formatVIPListError = [](HelixListVIPsError error, - const QString &message) -> QString { - using Error = HelixListVIPsError; + this->registerCommand("/vips", &commands::getVIPs); - QString errorMessage = QString("Failed to list VIPs - "); + this->registerCommand("/commercial", &commands::startCommercial); - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; + this->registerCommand("/unstable-set-user-color", + &commands::unstableSetUserClientSideColor); - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; + this->registerCommand("/debug-force-image-gc", + &commands::forceImageGarbageCollection); - 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 ""; - }); - - this->registerCommand( - "/debug-force-image-gc", - [](const QStringList & /*words*/, auto /*channel*/) -> QString { - runInGuiThread([] { - using namespace chatterino::detail; - auto &iep = ImageExpirationPool::instance(); - iep.freeOld(); - }); - return ""; - }); - - this->registerCommand( - "/debug-force-image-unload", - [](const QStringList & /*words*/, auto /*channel*/) -> QString { - runInGuiThread([] { - using namespace chatterino::detail; - auto &iep = ImageExpirationPool::instance(); - iep.freeAll(); - }); - return ""; - }); + this->registerCommand("/debug-force-image-unload", + &commands::forceImageUnload); this->registerCommand("/shield", &commands::shieldModeOn); this->registerCommand("/shieldoff", &commands::shieldModeOff); diff --git a/src/controllers/commands/builtin/Misc.cpp b/src/controllers/commands/builtin/Misc.cpp new file mode 100644 index 000000000..7100e7776 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.cpp @@ -0,0 +1,629 @@ +#include "controllers/commands/builtin/Misc.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/userdata/UserDataController.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "util/Clipboard.hpp" +#include "util/FormatTime.hpp" +#include "util/IncognitoBrowser.hpp" +#include "util/StreamLink.hpp" +#include "util/Twitch.hpp" +#include "widgets/dialogs/UserInfoPopup.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/Notebook.hpp" +#include "widgets/splits/Split.hpp" +#include "widgets/splits/SplitContainer.hpp" +#include "widgets/Window.hpp" + +#include +#include +#include + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.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 ""; +} + +QString unfollow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.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 ""; +} + +QString uptime(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /uptime command only works in Twitch Channels")); + return ""; + } + + const auto &streamStatus = ctx.twitchChannel->accessStreamStatus(); + + QString messageText = + streamStatus->live ? streamStatus->uptime : "Channel is not live."; + + ctx.channel->addMessage(makeSystemMessage(messageText)); + + return ""; +} + +QString user(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /user [channel]")); + return ""; + } + QString userName = ctx.words[1]; + stripUserName(userName); + + QString channelName = ctx.channel->getName(); + + if (ctx.words.size() > 2) + { + channelName = ctx.words[2]; + stripChannelName(channelName); + } + openTwitchUsercard(channelName, userName); + + return ""; +} + +QString requests(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.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 ""; +} + +QString lowtrust(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.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 ""; +} + +QString clip(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (const auto type = ctx.channel->getType(); + type != Channel::Type::Twitch && type != Channel::Type::TwitchWatching) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels")); + return ""; + } + + ctx.twitchChannel->createClip(); + + return ""; +} + +QString marker(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.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()) + { + ctx.channel->addMessage(makeSystemMessage( + "You need to be logged in to create stream markers!")); + return ""; + } + + // Exact same message as in webchat + if (!ctx.twitchChannel->isLive()) + { + ctx.channel->addMessage(makeSystemMessage( + "You can only add stream markers during live streams. Try " + "again when the channel is live streaming.")); + return ""; + } + + auto arguments = ctx.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 + ctx.twitchChannel->roomId(), arguments.join(" ").left(140), + [channel{ctx.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{ctx.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 ""; +} + +QString streamlink(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.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 ""; +} + +QString popout(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.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 ""; +} + +QString popup(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + 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(ctx.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 ""; + } + } + + ctx.channel->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 ""; +} + +QString clearmessages(const CommandContext &ctx) +{ + (void)ctx; + + auto *currentPage = dynamic_cast( + getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + + if (auto *split = currentPage->getSelectedSplit()) + { + split->getChannelView().clearMessages(); + } + + return ""; +} + +QString openURL(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /openurl ")); + return ""; + } + + QUrl url = QUrl::fromUserInput(ctx.words.mid(1).join(" ")); + if (!url.isValid()) + { + ctx.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) + { + ctx.channel->addMessage(makeSystemMessage("Could not open URL.")); + } + + return ""; +} + +QString sendRawMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.channel->isTwitchChannel()) + { + getApp()->twitch->sendRawMessage(ctx.words.mid(1).join(" ")); + } + else + { + // other code down the road handles this for IRC + return ctx.words.join(" "); + } + return ""; +} + +QString injectFakeMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.channel->addMessage(makeSystemMessage( + "The /fakemsg command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /fakemsg (raw irc text) - injects raw irc text as " + "if it was a message received from TMI")); + return ""; + } + + auto ircText = ctx.words.mid(1).join(" "); + getApp()->twitch->addFakeMessage(ircText); + + return ""; +} + +QString copyToClipboard(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /copy - copies provided " + "text to clipboard.")); + return ""; + } + + crossPlatformCopy(ctx.words.mid(1).join(" ")); + return ""; +} + +QString unstableSetUserClientSideColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + 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 ""; +} + +QString openUsercard(const CommandContext &ctx) +{ + auto channel = ctx.channel; + + if (channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: /usercard [channel] or " + "/usercard id: [channel]")); + return ""; + } + + QString userName = ctx.words[1]; + stripUserName(userName); + + if (ctx.words.size() > 2) + { + QString channelName = ctx.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->moveTo(QCursor::pos(), widgets::BoundsChecking::CursorPosition); + userPopup->show(); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/Misc.hpp b/src/controllers/commands/builtin/Misc.hpp new file mode 100644 index 000000000..7a8be28c7 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.hpp @@ -0,0 +1,32 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx); +QString unfollow(const CommandContext &ctx); +QString uptime(const CommandContext &ctx); +QString user(const CommandContext &ctx); +QString requests(const CommandContext &ctx); +QString lowtrust(const CommandContext &ctx); +QString clip(const CommandContext &ctx); +QString marker(const CommandContext &ctx); +QString streamlink(const CommandContext &ctx); +QString popout(const CommandContext &ctx); +QString popup(const CommandContext &ctx); +QString clearmessages(const CommandContext &ctx); +QString openURL(const CommandContext &ctx); +QString sendRawMessage(const CommandContext &ctx); +QString injectFakeMessage(const CommandContext &ctx); +QString copyToClipboard(const CommandContext &ctx); +QString unstableSetUserClientSideColor(const CommandContext &ctx); +QString openUsercard(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index 7ae1ce947..c72f0cde0 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -1,11 +1,16 @@ #include "controllers/commands/builtin/chatterino/Debugging.hpp" #include "common/Channel.hpp" +#include "common/Env.hpp" #include "common/Literals.hpp" #include "controllers/commands/CommandContext.hpp" +#include "messages/Image.hpp" #include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" #include "singletons/Theme.hpp" +#include "util/PostToThread.hpp" +#include #include #include @@ -63,4 +68,70 @@ QString toggleThemeReload(const CommandContext &ctx) return {}; } +QString listEnvironmentVariables(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + 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 ""; +} + +QString listArgs(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + QString msg = QApplication::instance()->arguments().join(' '); + + channel->addMessage(makeSystemMessage(msg)); + + return ""; +} + +QString forceImageGarbageCollection(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeOld(); + }); + return ""; +} + +QString forceImageUnload(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeAll(); + }); + return ""; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.hpp b/src/controllers/commands/builtin/chatterino/Debugging.hpp index 8b531455f..8d1857370 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.hpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.hpp @@ -14,4 +14,12 @@ QString setLoggingRules(const CommandContext &ctx); QString toggleThemeReload(const CommandContext &ctx); +QString listEnvironmentVariables(const CommandContext &ctx); + +QString listArgs(const CommandContext &ctx); + +QString forceImageGarbageCollection(const CommandContext &ctx); + +QString forceImageUnload(const CommandContext &ctx); + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.cpp b/src/controllers/commands/builtin/twitch/AddModerator.cpp new file mode 100644 index 000000000..5f244c0ac --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.cpp @@ -0,0 +1,131 @@ +#include "controllers/commands/builtin/twitch/AddModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mod command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to mod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.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{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.hpp b/src/controllers/commands/builtin/twitch/AddModerator.hpp new file mode 100644 index 000000000..722ad724b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mod +QString addModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.cpp b/src/controllers/commands/builtin/twitch/AddVIP.cpp new file mode 100644 index 000000000..11ecaed49 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/AddVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vip command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to VIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.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{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.hpp b/src/controllers/commands/builtin/twitch/AddVIP.hpp new file mode 100644 index 000000000..3d956cc42 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vip +QString addVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.cpp b/src/controllers/commands/builtin/twitch/Announce.cpp new file mode 100644 index 000000000..869f25add --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.cpp @@ -0,0 +1,81 @@ +#include "controllers/commands/builtin/twitch/Announce.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString sendAnnouncement(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "This command can only be used in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /announce - Call attention to your " + "message with a highlight.")); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /announce command")); + return ""; + } + + getHelix()->sendChatAnnouncement( + ctx.twitchChannel->roomId(), user->getUserId(), + ctx.words.mid(1).join(" "), HelixAnnouncementColor::Primary, + []() { + // do nothing. + }, + [channel{ctx.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 ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.hpp b/src/controllers/commands/builtin/twitch/Announce.hpp new file mode 100644 index 000000000..3904d1a20 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /announce +QString sendAnnouncement(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.cpp b/src/controllers/commands/builtin/twitch/Block.cpp new file mode 100644 index 000000000..cb35c23b6 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.cpp @@ -0,0 +1,166 @@ +#include "controllers/commands/builtin/twitch/Block.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +} // namespace + +namespace chatterino::commands { + +QString blockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /block command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /block ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to block someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, + target](const HelixUser &targetUser) { + getApp()->accounts->twitch.getCurrent()->blockUser( + targetUser.id, nullptr, + [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{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be blocked, no " + "user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString ignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Ignore command has been renamed to /block, please use it from " + "now on as /ignore is going to be removed soon.")); + + return blockUser(ctx); +} + +QString unblockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unblock command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /unblock ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unblock someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, target](const auto &targetUser) { + getApp()->accounts->twitch.getCurrent()->unblockUser( + targetUser.id, nullptr, + [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{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be unblocked, " + "no user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString unignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Unignore command has been renamed to /unblock, please use it " + "from now on as /unignore is going to be removed soon.")); + return unblockUser(ctx); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.hpp b/src/controllers/commands/builtin/twitch/Block.hpp new file mode 100644 index 000000000..75ea3d0d4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.hpp @@ -0,0 +1,25 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /block +QString blockUser(const CommandContext &ctx); + +/// /ignore +QString ignoreUser(const CommandContext &ctx); + +/// /unblock +QString unblockUser(const CommandContext &ctx); + +/// /unignore +QString unignoreUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Chatters.cpp b/src/controllers/commands/builtin/twitch/Chatters.cpp new file mode 100644 index 000000000..23ee43fdc --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.cpp @@ -0,0 +1,143 @@ +#include "controllers/commands/builtin/twitch/Chatters.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/Env.hpp" +#include "common/Literals.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/Theme.hpp" + +#include +#include +#include + +namespace { + +using namespace chatterino; + +QString formatChattersError(HelixGetChattersError error, const 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; +} + +} // namespace + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /chatters command only works in Twitch Channels")); + return ""; + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 1, + [channel{ctx.channel}](auto result) { + channel->addMessage( + makeSystemMessage(QString("Chatter count: %1") + .arg(localizeNumbers(result.total)))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +QString testChatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /test-chatters command only works in Twitch Channels")); + return ""; + } + + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, + [channel{ctx.channel}, twitchChannel{ctx.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{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Chatters.hpp b/src/controllers/commands/builtin/twitch/Chatters.hpp new file mode 100644 index 000000000..25b34bab9 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.hpp @@ -0,0 +1,17 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx); + +QString testChatters(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.cpp b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp new file mode 100644 index 000000000..917a43730 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp @@ -0,0 +1,162 @@ +#include "controllers/commands/builtin/twitch/DeleteMessages.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/NetworkResult.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace { + +using namespace chatterino; + +QString 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 ""; +} + +} // namespace + +namespace chatterino::commands { + +QString deleteAllMessages(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clear command only works in Twitch channels")); + return ""; + } + + return deleteMessages(ctx.twitchChannel, QString()); +} + +QString deleteOneMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + // 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 + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /delete command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /delete - Deletes the " + "specified message.")); + return ""; + } + + auto messageID = ctx.words.at(1); + auto uuid = QUuid(messageID); + if (uuid.isNull()) + { + // The message id must be a valid UUID + ctx.channel->addMessage(makeSystemMessage( + QString("Invalid msg-id: \"%1\"").arg(messageID))); + return ""; + } + + auto msg = ctx.channel->findMessage(messageID); + if (msg != nullptr) + { + if (msg->loginName == ctx.channel->getName() && + !ctx.channel->isBroadcaster()) + { + ctx.channel->addMessage(makeSystemMessage( + "You cannot delete the broadcaster's messages unless " + "you are the broadcaster.")); + return ""; + } + } + + return deleteMessages(ctx.twitchChannel, messageID); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.hpp b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp new file mode 100644 index 000000000..24daae930 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp @@ -0,0 +1,19 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /clear +QString deleteAllMessages(const CommandContext &ctx); + +/// /delete +QString deleteOneMessage(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.cpp b/src/controllers/commands/builtin/twitch/GetModerators.cpp new file mode 100644 index 000000000..cc3a97ee0 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.cpp @@ -0,0 +1,94 @@ +#include "controllers/commands/builtin/twitch/GetModerators.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatModsError(HelixGetModeratorsError error, const 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; +} + +} // namespace + +namespace chatterino::commands { + +QString getModerators(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mods command only works in Twitch Channels")); + return ""; + } + + getHelix()->getModerators( + ctx.twitchChannel->roomId(), 500, + [channel{ctx.channel}, twitchChannel{ctx.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{ctx.channel}](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.hpp b/src/controllers/commands/builtin/twitch/GetModerators.hpp new file mode 100644 index 000000000..517533246 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mods +QString getModerators(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.cpp b/src/controllers/commands/builtin/twitch/GetVIPs.cpp new file mode 100644 index 000000000..74a59a357 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.cpp @@ -0,0 +1,124 @@ +#include "controllers/commands/builtin/twitch/GetVIPs.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatGetVIPsError(HelixListVIPsError error, const QString &message) +{ + 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; +} + +} // namespace + +namespace chatterino::commands { + +QString getVIPs(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vips command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.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( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}, twitchChannel{ctx.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{ctx.channel}](auto error, auto message) { + auto errorMessage = formatGetVIPsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.hpp b/src/controllers/commands/builtin/twitch/GetVIPs.hpp new file mode 100644 index 000000000..018537590 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vips +QString getVIPs(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.cpp b/src/controllers/commands/builtin/twitch/Raid.cpp new file mode 100644 index 000000000..b1cf03014 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.cpp @@ -0,0 +1,220 @@ +#include "controllers/commands/builtin/twitch/Raid.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartRaidError(HelixStartRaidError error, const QString &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; + } + + return errorMessage; +} + +QString formatCancelRaidError(HelixCancelRaidError error, + const QString &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; + } + + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString startRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /raid command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to start a raid!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.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) { + auto errorMessage = formatStartRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +QString cancelRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unraid command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() != 1) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to cancel the raid!")); + return ""; + } + + getHelix()->cancelRaid( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage(QString("You cancelled the raid."))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatCancelRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.hpp b/src/controllers/commands/builtin/twitch/Raid.hpp new file mode 100644 index 000000000..38d37644d --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /raid +QString startRaid(const CommandContext &ctx); + +/// /unraid +QString cancelRaid(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.cpp b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp new file mode 100644 index 000000000..c4ed62364 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp @@ -0,0 +1,122 @@ +#include "controllers/commands/builtin/twitch/RemoveModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unmod command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unmod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.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{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.hpp b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp new file mode 100644 index 000000000..9b6894dc7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unmod +QString removeModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.cpp b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp new file mode 100644 index 000000000..6922175ae --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/RemoveVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unvip command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.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()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to UnVIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.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{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.hpp b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp new file mode 100644 index 000000000..ec66c62e7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unvip +QString removeVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.cpp b/src/controllers/commands/builtin/twitch/SendReply.cpp new file mode 100644 index 000000000..a88fb0b5e --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.cpp @@ -0,0 +1,63 @@ +#include "controllers/commands/builtin/twitch/SendReply.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageThread.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /reply command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /reply ")); + return ""; + } + + QString username = ctx.words[1]; + stripChannelName(username); + + auto snapshot = ctx.twitchChannel->getMessageSnapshot(); + for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) + { + const auto &msg = *it; + if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) + { + // found most recent message by user + if (msg->replyThread == nullptr) + { + // prepare thread if one does not exist + auto thread = std::make_shared(msg); + ctx.twitchChannel->addReplyThread(thread); + } + + QString reply = ctx.words.mid(2).join(" "); + ctx.twitchChannel->sendReply(reply, msg->id); + return ""; + } + } + + ctx.channel->addMessage( + makeSystemMessage("A message from that user wasn't found")); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.hpp b/src/controllers/commands/builtin/twitch/SendReply.hpp new file mode 100644 index 000000000..0909ae047 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.cpp b/src/controllers/commands/builtin/twitch/SendWhisper.cpp new file mode 100644 index 000000000..78efc6044 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.cpp @@ -0,0 +1,258 @@ +#include "controllers/commands/builtin/twitch/SendWhisper.hpp" + +#include "Application.hpp" +#include "common/LinkParser.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.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/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatWhisperError(HelixWhisperError error, const QString &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; + } + + return errorMessage; +} + +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 = std::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, 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 + { + LinkParser parser(string); + if (parser.result()) + { + b.addLink(*parser.result()); + } + else + { + b.emplace(string, + MessageElementFlag::Text); + } + } + } 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 = std::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; +} + +} // namespace + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /w ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to send a whisper!")); + return ""; + } + auto target = ctx.words.at(1); + stripChannelName(target); + auto message = ctx.words.mid(2).join(' '); + if (ctx.channel->isTwitchChannel()) + { + getHelix()->getUserByName( + target, + [channel{ctx.channel}, currentUser, target, message, + words{ctx.words}](const auto &targetUser) { + getHelix()->sendWhisper( + currentUser->getUserId(), targetUser.id, message, + [words] { + appendWhisperMessageWordsLocally(words); + }, + [channel, target, targetUser](auto error, auto message) { + auto errorMessage = formatWhisperError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("No user matching that username.")); + }); + return ""; + } + + // we must be on IRC + auto *ircChannel = dynamic_cast(ctx.channel.get()); + if (ircChannel == nullptr) + { + // give up + return ""; + } + + auto *server = ircChannel->server(); + server->sendWhisper(target, message); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.hpp b/src/controllers/commands/builtin/twitch/SendWhisper.hpp new file mode 100644 index 000000000..1e882a936 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.cpp b/src/controllers/commands/builtin/twitch/StartCommercial.cpp new file mode 100644 index 000000000..545099a7f --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.cpp @@ -0,0 +1,136 @@ +#include "controllers/commands/builtin/twitch/StartCommercial.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartCommercialError(HelixStartCommercialError error, + const QString &message) +{ + 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; +} + +} // namespace + +namespace chatterino::commands { + +QString startCommercial(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.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."; + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /commercial command")); + return ""; + } + + auto broadcasterID = ctx.twitchChannel->roomId(); + auto length = ctx.words.at(1).toInt(); + + getHelix()->startCommercial( + broadcasterID, length, + [channel{ctx.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{ctx.channel}](auto error, auto message) { + auto errorMessage = formatStartCommercialError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.hpp b/src/controllers/commands/builtin/twitch/StartCommercial.hpp new file mode 100644 index 000000000..3b1d550fc --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /commercial +QString startCommercial(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp new file mode 100644 index 000000000..02b942494 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -0,0 +1,124 @@ +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/builtin/twitch/Ban.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString unbanUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + auto commandName = ctx.words.at(0).toLower(); + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + QString("The %1 command only works in Twitch channels") + .arg(commandName))); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: \"%1 \" - Removes a ban on a user.") + .arg(commandName))); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unban someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [channel{ctx.channel}, currentUser, twitchChannel{ctx.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{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.hpp b/src/controllers/commands/builtin/twitch/Unban.hpp new file mode 100644 index 000000000..4c32f09b7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unban +QString unbanUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.cpp b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp new file mode 100644 index 000000000..780be75e7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp @@ -0,0 +1,121 @@ +#include "controllers/commands/builtin/twitch/UpdateChannel.hpp" + +#include "common/Channel.hpp" +#include "common/NetworkResult.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /settitle ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set title of non-Twitch channel.")); + return ""; + } + + auto status = ctx.twitchChannel->accessStreamStatus(); + auto title = ctx.words.mid(1).join(" "); + getHelix()->updateChannel( + ctx.twitchChannel->roomId(), "", "", title, + [channel{ctx.channel}, title](const auto &result) { + (void)result; + + channel->addMessage( + makeSystemMessage(QString("Updated title to %1").arg(title))); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("Title update failed! Are you " + "missing the required scope?")); + }); + + return ""; +} + +QString setGame(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /setgame ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set game of non-Twitch channel.")); + return ""; + } + + const auto gameName = ctx.words.mid(1).join(" "); + + getHelix()->searchGames( + gameName, + [channel{ctx.channel}, twitchChannel{ctx.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{ctx.channel}] { + channel->addMessage(makeSystemMessage("Failed to look up game.")); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.hpp b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp new file mode 100644 index 000000000..2a085b49c --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx); +QString setGame(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.cpp b/src/controllers/commands/builtin/twitch/UpdateColor.cpp new file mode 100644 index 000000000..8057daee6 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.cpp @@ -0,0 +1,99 @@ +#include "controllers/commands/builtin/twitch/UpdateColor.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.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()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /color command")); + return ""; + } + + auto colorString = ctx.words.value(1); + + if (colorString.isEmpty()) + { + ctx.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{ctx.channel}] { + QString successMessage = + QString("Your color has been changed to %1.").arg(colorString); + channel->addMessage(makeSystemMessage(successMessage)); + }, + [colorString, channel{ctx.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 ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.hpp b/src/controllers/commands/builtin/twitch/UpdateColor.hpp new file mode 100644 index 000000000..c4c3bdaf0 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index a2f8d0d87..6e1052a8a 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -121,10 +121,7 @@ ImagePtr getEmptyImagePtr(); class ImageExpirationPool { -private: - friend class Image; - friend class CommandController; - +public: ImageExpirationPool(); static ImageExpirationPool &instance(); @@ -145,7 +142,6 @@ private: */ void freeAll(); -private: // Timer to periodically run freeOld() QTimer *freeTimer_; std::map> allImages_;