diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index 9ec2b2a0a..fcf0b5dac 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -1,77 +1,77 @@ - - chatterino2.icns - contributors.txt - emoji.json - emojidata.txt - error.png - icon.ico - icon.png - pajaDank.png - tlds.txt - avatars/fourtf.png - avatars/pajlada.png - buttons/addSplit.png - buttons/addSplitDark.png - buttons/ban.png - buttons/banRed.png - buttons/emote.svg - buttons/emoteDark.svg - buttons/menuDark.png - buttons/menuLight.png - buttons/mod.png - buttons/modModeDisabled.png - buttons/modModeDisabled2.png - buttons/modModeEnabled.png - buttons/modModeEnabled2.png - buttons/timeout.png - buttons/unban.png - buttons/unmod.png - buttons/update.png - buttons/updateError.png - examples/moving.gif - examples/splitting.gif - licenses/boost_boost.txt - licenses/emoji-data-source.txt - licenses/fmt_bsd2.txt - licenses/libcommuni_BSD3.txt - licenses/openssl.txt - licenses/pajlada_settings.txt - licenses/pajlada_signals.txt - licenses/qt_lgpl-3.0.txt - licenses/rapidjson.txt - licenses/websocketpp.txt - qss/settings.qss - settings/about.svg - settings/aboutlogo.png - settings/accounts.svg - settings/behave.svg - settings/commands.svg - settings/emote.svg - settings/notifications.svg - settings/theme.svg - sounds/ping2.wav - split/down.png - split/left.png - split/move.png - split/right.png - split/up.png - twitch/admin.png - twitch/broadcaster.png - twitch/cheer1.png - twitch/globalmod.png - twitch/moderator.png - twitch/prime.png - twitch/staff.png - twitch/subscriber.png - twitch/turbo.png - twitch/verified.png - settings/ignore.svg - settings/keybinds.svg - settings/moderation.svg - settings/notification2.svg - settings/browser.svg - settings/externaltools.svg - settings/advanced.svg - - + chatterino2.icns + contributors.txt + emoji.json + emojidata.txt + error.png + icon.ico + icon.png + pajaDank.png + tlds.txt + avatars/fourtf.png + avatars/pajlada.png + buttons/addSplit.png + buttons/addSplitDark.png + buttons/ban.png + buttons/banRed.png + buttons/emote.svg + buttons/emoteDark.svg + buttons/menuDark.png + buttons/menuLight.png + buttons/mod.png + buttons/modModeDisabled.png + buttons/modModeDisabled2.png + buttons/modModeEnabled.png + buttons/modModeEnabled2.png + buttons/timeout.png + buttons/unban.png + buttons/unmod.png + buttons/update.png + buttons/updateError.png + examples/moving.gif + examples/splitting.gif + licenses/boost_boost.txt + licenses/emoji-data-source.txt + licenses/fmt_bsd2.txt + licenses/libcommuni_BSD3.txt + licenses/openssl.txt + licenses/pajlada_settings.txt + licenses/pajlada_signals.txt + licenses/qt_lgpl-3.0.txt + licenses/rapidjson.txt + licenses/websocketpp.txt + qss/settings.qss + settings/about.svg + settings/aboutlogo.png + settings/accounts.svg + settings/advanced.svg + settings/behave.svg + settings/browser.svg + settings/commands.svg + settings/emote.svg + settings/externaltools.svg + settings/ignore.svg + settings/keybinds.svg + settings/moderation.svg + settings/notification2.svg + settings/notifications.svg + settings/theme.svg + sounds/ping2.wav + split/down.png + split/left.png + split/move.png + split/right.png + split/up.png + twitch/admin.png + twitch/automod.png + twitch/broadcaster.png + twitch/cheer1.png + twitch/globalmod.png + twitch/moderator.png + twitch/prime.png + twitch/staff.png + twitch/subscriber.png + twitch/turbo.png + twitch/verified.png + + \ No newline at end of file diff --git a/resources/twitch/automod.png b/resources/twitch/automod.png new file mode 100644 index 000000000..01174644f Binary files /dev/null and b/resources/twitch/automod.png differ diff --git a/src/Application.cpp b/src/Application.cpp index 107f0b05f..2870c6ed6 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -234,6 +234,38 @@ void Application::initPubsub() postToThread([chan, msg] { chan->addMessage(msg); }); }); + this->twitch.pubsub->signals_.moderation.automodMessage.connect( + [&](const auto &action) { + auto chan = + this->twitch.server->getChannelOrEmptyByID(action.roomID); + + if (chan->isEmpty()) + { + return; + } + + postToThread([chan, action] { + auto p = makeAutomodMessage(action); + chan->addMessage(p.first); + chan->addMessage(p.second); + }); + }); + + this->twitch.pubsub->signals_.moderation.automodUserMessage.connect( + [&](const auto &action) { + auto chan = + this->twitch.server->getChannelOrEmptyByID(action.roomID); + + if (chan->isEmpty()) + { + return; + } + + auto msg = MessageBuilder(action).release(); + + postToThread([chan, msg] { chan->addMessage(msg); }); + }); + this->twitch.pubsub->start(); auto RequestModerationActions = [=]() { diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index ee25daf1f..34cf039ef 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -32,6 +32,7 @@ Resources2::Resources2() this->split.right = QPixmap(":/split/right.png"); this->split.up = QPixmap(":/split/up.png"); this->twitch.admin = QPixmap(":/twitch/admin.png"); + this->twitch.automod = QPixmap(":/twitch/automod.png"); this->twitch.broadcaster = QPixmap(":/twitch/broadcaster.png"); this->twitch.cheer1 = QPixmap(":/twitch/cheer1.png"); this->twitch.globalmod = QPixmap(":/twitch/globalmod.png"); diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index 075c801eb..fbbb2fbf3 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -3,8 +3,7 @@ namespace chatterino { -class Resources2 : public Singleton -{ +class Resources2 : public Singleton { public: Resources2(); @@ -45,6 +44,7 @@ public: } split; struct { QPixmap admin; + QPixmap automod; QPixmap broadcaster; QPixmap cheer1; QPixmap globalmod; diff --git a/src/common/NetworkRequest.cpp b/src/common/NetworkRequest.cpp index 524693c17..b4192e8e6 100644 --- a/src/common/NetworkRequest.cpp +++ b/src/common/NetworkRequest.cpp @@ -147,6 +147,12 @@ void NetworkRequest::execute() } break; + case NetworkRequestType::Post: + { + this->doRequest(); + } + break; + default: { log("[Execute] Unhandled request type"); @@ -217,6 +223,10 @@ void NetworkRequest::doRequest() return NetworkManager::accessManager.deleteResource( data->request_); + case NetworkRequestType::Post: + return NetworkManager::accessManager.post(data->request_, + data->payload_); + default: return nullptr; } diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index 38000982f..52a52444b 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -17,6 +17,8 @@ public: InsertText, ShowMessage, UserAction, + AutoModAllow, + AutoModDeny, }; Link(); diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index cc716dd2f..572615a04 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -28,6 +28,7 @@ enum class MessageFlag : uint16_t { PubSub = (1 << 11), Subscription = (1 << 12), Notification = (1 << 13), + AutoMod = (1 << 14), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 7ea91fcfa..9a4b1a00d 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -1,6 +1,8 @@ #include "MessageBuilder.hpp" +#include "Application.hpp" #include "common/LinkParser.hpp" +#include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/PubsubActions.hpp" @@ -11,6 +13,7 @@ #include "util/IrcHelpers.hpp" #include +#include namespace chatterino { @@ -19,6 +22,61 @@ MessagePtr makeSystemMessage(const QString &text) return MessageBuilder(systemMessage, text).release(); } +std::pair makeAutomodMessage( + const AutomodAction &action) +{ + auto builder = MessageBuilder(); + + builder.emplace(); + builder.message().flags.set(MessageFlag::PubSub); + + builder + .emplace( + Image::fromPixmap(getApp()->resources->twitch.automod), + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip("AutoMod"); + builder.emplace( + "AutoMod:", MessageElementFlag::NonBoldUsername, + MessageColor(QColor("blue"))); + builder.emplace( + ("Held a message for reason: " + action.reason + + ". Allow will post it in chat. "), + MessageElementFlag::Text, MessageColor::Text); + builder + .emplace("Allow", MessageElementFlag::Text, + MessageColor(QColor("green")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModAllow, action.msgID}); + builder + .emplace(" Deny", MessageElementFlag::Text, + MessageColor(QColor("red")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModDeny, action.msgID}); + // builder.emplace(action.msgID, + // MessageElementFlag::Text, + // MessageColor::Text); + builder.message().flags.set(MessageFlag::AutoMod); + + auto message1 = builder.release(); + + builder = MessageBuilder(); + builder.emplace(); + builder.message().flags.set(MessageFlag::PubSub); + + builder + .emplace(action.target.name + ":", + MessageElementFlag::NonBoldUsername, + MessageColor(QColor("red"))) + ->setLink({Link::UserInfo, action.target.name}); + builder.emplace(action.message, MessageElementFlag::Text, + MessageColor::Text); + builder.message().flags.set(MessageFlag::AutoMod); + + auto message2 = builder.release(); + + return std::make_pair(message1, message2); +} + MessageBuilder::MessageBuilder() : message_(std::make_shared()) { @@ -179,6 +237,47 @@ MessageBuilder::MessageBuilder(const UnbanAction &action) this->message().searchText = text; } +MessageBuilder::MessageBuilder(const AutomodUserAction &action) + : MessageBuilder() +{ + this->emplace(); + this->message().flags.set(MessageFlag::System); + + QString text; + if (action.type == 1) + { + text = QString("%1 added %2 as a permitted term on AutoMod.") + .arg(action.source.name) + .arg(action.message); + } + else if (action.type == 2) + { + text = QString("%1 added %2 as a blocked term on AutoMod.") + .arg(action.source.name) + .arg(action.message); + } + else if (action.type == 3) + { + text = QString("%1 removed %2 as a permitted term term on AutoMod.") + .arg(action.source.name) + .arg(action.message); + } + else if (action.type == 4) + { + text = QString("%1 removed %2 as a blocked term on AutoMod.") + .arg(action.source.name) + .arg(action.message); + } + else if (action.type == 5) + { + text = QString("%1 modified the AutoMod properties.") + .arg(action.source.name); + } + + this->emplace(text, MessageElementFlag::Text, + MessageColor::System); +} + Message *MessageBuilder::operator->() { return this->message_.get(); diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index b09b82ed2..8fe7915e2 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -4,10 +4,13 @@ #include #include +#include namespace chatterino { struct BanAction; struct UnbanAction; +struct AutomodAction; +struct AutomodUserAction; struct Message; using MessagePtr = std::shared_ptr; @@ -19,6 +22,8 @@ const SystemMessageTag systemMessage{}; const TimeoutMessageTag timeoutMessage{}; MessagePtr makeSystemMessage(const QString &text); +std::pair makeAutomodMessage( + const AutomodAction &action); struct MessageParseArgs { bool disablePingSounds = false; @@ -29,6 +34,7 @@ struct MessageParseArgs { }; class MessageBuilder + { public: MessageBuilder(); @@ -39,6 +45,7 @@ public: bool multipleTimes); MessageBuilder(const BanAction &action, uint32_t count = 1); MessageBuilder(const UnbanAction &action); + MessageBuilder(const AutomodUserAction &action); Message *operator->(); Message &message(); @@ -63,5 +70,4 @@ public: private: std::shared_ptr message_; }; - } // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index cf1be4a72..06af7c790 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -268,6 +268,10 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, { backgroundColor = app->themes->messages.backgrounds.alternate; } + else if (this->message_->flags.has(MessageFlag::AutoMod)) + { + backgroundColor = QColor("#404040"); + } painter.fillRect(buffer->rect(), backgroundColor); diff --git a/src/providers/twitch/PubsubActions.hpp b/src/providers/twitch/PubsubActions.hpp index 8b2bfecfb..6377b6d4e 100644 --- a/src/providers/twitch/PubsubActions.hpp +++ b/src/providers/twitch/PubsubActions.hpp @@ -105,4 +105,26 @@ struct ModerationStateAction : PubSubAction { bool modded; }; +struct AutomodAction : PubSubAction { + using PubSubAction::PubSubAction; + + ActionUser target; + + QString message; + + QString reason; + + QString msgID; +}; + +struct AutomodUserAction : PubSubAction { + using PubSubAction::PubSubAction; + + ActionUser target; + + QString message; + + qint8 type; +}; + } // namespace chatterino diff --git a/src/providers/twitch/PubsubClient.cpp b/src/providers/twitch/PubsubClient.cpp index 6bc866700..f5fb54255 100644 --- a/src/providers/twitch/PubsubClient.cpp +++ b/src/providers/twitch/PubsubClient.cpp @@ -520,6 +520,196 @@ PubSub::PubSub() } }; + this->moderationActionHandlers["automod_rejected"] = + [this](const auto &data, const auto &roomID) { + // Display the automod message and prompt the allow/deny + AutomodAction action(data, roomID); + + getCreatedByUser(data, action.source); + getTargetUser(data, action.target); + + try + { + const auto &args = getArgs(data); + const auto &msgID = getMsgID(data); + + if (args.Size() < 1) + { + return; + } + + if (!rj::getSafe(args[0], action.target.name)) + { + return; + } + + if (args.Size() >= 2) + { + if (!rj::getSafe(args[1], action.message)) + { + return; + } + } + + if (args.Size() >= 3) + { + if (!rj::getSafe(args[2], action.reason)) + { + return; + } + } + + if (!rj::getSafe(msgID, action.msgID)) + { + return; + } + + this->signals_.moderation.automodMessage.invoke(action); + } + catch (const std::runtime_error &ex) + { + log("Error parsing moderation action: {}", ex.what()); + } + }; + + this->moderationActionHandlers["add_permitted_term"] = + [this](const auto &data, const auto &roomID) { + // This term got a pass through automod + AutomodUserAction action(data, roomID); + getCreatedByUser(data, action.source); + + try + { + const auto &args = getArgs(data); + action.type = 1; + + if (args.Size() < 1) + { + return; + } + + if (!rj::getSafe(args[0], action.message)) + { + return; + } + + this->signals_.moderation.automodUserMessage.invoke(action); + } + catch (const std::runtime_error &ex) + { + log("Error parsing moderation action: {}", ex.what()); + } + }; + + this->moderationActionHandlers["add_blocked_term"] = + [this](const auto &data, const auto &roomID) { + // A term has been added + AutomodUserAction action(data, roomID); + getCreatedByUser(data, action.source); + + try + { + const auto &args = getArgs(data); + action.type = 2; + + if (args.Size() < 1) + { + return; + } + + if (!rj::getSafe(args[0], action.message)) + { + return; + } + + this->signals_.moderation.automodUserMessage.invoke(action); + } + catch (const std::runtime_error &ex) + { + log("Error parsing moderation action: {}", ex.what()); + } + }; + + this->moderationActionHandlers["delete_permitted_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + getCreatedByUser(data, action.source); + + try + { + const auto &args = getArgs(data); + action.type = 3; + + if (args.Size() < 1) + { + return; + } + + if (!rj::getSafe(args[0], action.message)) + { + return; + } + + this->signals_.moderation.automodUserMessage.invoke(action); + } + catch (const std::runtime_error &ex) + { + log("Error parsing moderation action: {}", ex.what()); + } + }; + + this->moderationActionHandlers["delete_blocked_term"] = + [this](const auto &data, const auto &roomID) { + // This term got deleted + AutomodUserAction action(data, roomID); + + getCreatedByUser(data, action.source); + + try + { + const auto &args = getArgs(data); + action.type = 4; + + if (args.Size() < 1) + { + return; + } + + if (!rj::getSafe(args[0], action.message)) + { + return; + } + + this->signals_.moderation.automodUserMessage.invoke(action); + } + catch (const std::runtime_error &ex) + { + log("Error parsing moderation action: {}", ex.what()); + } + }; + + this->moderationActionHandlers["modified_automod_properties"] = + [this](const auto &data, const auto &roomID) { + // The automod settings got modified + AutomodUserAction action(data, roomID); + getCreatedByUser(data, action.source); + action.type = 5; + this->signals_.moderation.automodUserMessage.invoke(action); + }; + + this->moderationActionHandlers["denied_automod_message"] = + [this](const auto &data, const auto &roomID) { + // This message got denied by a moderator + // qDebug() << QString::fromStdString(rj::stringify(data)); + }; + + this->moderationActionHandlers["approved_automod_message"] = + [this](const auto &data, const auto &roomID) { + // This message got approved by a moderator + // qDebug() << QString::fromStdString(rj::stringify(data)); + }; + this->websocketClient.set_access_channels(websocketpp::log::alevel::all); this->websocketClient.clear_access_channels( websocketpp::log::alevel::frame_payload); @@ -539,7 +729,7 @@ PubSub::PubSub() // Add an initial client this->addClient(); -} +} // namespace chatterino void PubSub::addClient() { diff --git a/src/providers/twitch/PubsubClient.hpp b/src/providers/twitch/PubsubClient.hpp index f3cc10f15..9e2def087 100644 --- a/src/providers/twitch/PubsubClient.hpp +++ b/src/providers/twitch/PubsubClient.hpp @@ -112,6 +112,9 @@ public: Signal userBanned; Signal userUnbanned; + + Signal automodMessage; + Signal automodUserMessage; } moderation; struct { diff --git a/src/providers/twitch/PubsubHelpers.cpp b/src/providers/twitch/PubsubHelpers.cpp index cda4b8788..b30c47c0f 100644 --- a/src/providers/twitch/PubsubHelpers.cpp +++ b/src/providers/twitch/PubsubHelpers.cpp @@ -23,6 +23,18 @@ const rapidjson::Value &getArgs(const rapidjson::Value &data) return args; } +const rapidjson::Value &getMsgID(const rapidjson::Value &data) +{ + if (!data.HasMember("msg_id")) + { + throw std::runtime_error("Missing member msg_id"); + } + + const auto &msgID = data["msg_id"]; + + return msgID; +} + bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user) { return rj::getSafe(data, "created_by", user.name) && diff --git a/src/providers/twitch/PubsubHelpers.hpp b/src/providers/twitch/PubsubHelpers.hpp index 7ea540ad8..ad8c52130 100644 --- a/src/providers/twitch/PubsubHelpers.hpp +++ b/src/providers/twitch/PubsubHelpers.hpp @@ -12,6 +12,7 @@ class TwitchAccount; struct ActionUser; const rapidjson::Value &getArgs(const rapidjson::Value &data); +const rapidjson::Value &getMsgID(const rapidjson::Value &data); bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user); diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index f6337d0c6..1d798f8ce 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -413,6 +413,51 @@ AccessGuard return this->emotes_.accessConst(); } +// AutoModActions +void TwitchAccount::autoModAllow(const QString msgID) +{ + QString url("https://api.twitch.tv/kraken/chat/twitchbot/approve"); + + NetworkRequest req(url, NetworkRequestType::Post); + req.setRawHeader("Content-Type", "application/json"); + + auto qba = (QString("{\"msg_id\":\"") + msgID + "\"}").toUtf8(); + qDebug() << qba; + + req.setRawHeader("Content-Length", QByteArray::number(qba.size())); + req.setPayload(qba); + req.setCaller(QThread::currentThread()); + req.makeAuthorizedV5(this->getOAuthClient(), this->getOAuthToken()); + + req.onError([=](int errorCode) { + log("[TwitchAccounts::autoModAllow] Error {}", errorCode); + return true; + }); + + req.execute(); +} + +void TwitchAccount::autoModDeny(const QString msgID) +{ + QString url("https://api.twitch.tv/kraken/chat/twitchbot/deny"); + + NetworkRequest req(url, NetworkRequestType::Post); + req.setRawHeader("Content-Type", "application/json"); + auto qba = (QString("{\"msg_id\":\"") + msgID + "\"}").toUtf8(); + qDebug() << qba; + + req.setRawHeader("Content-Length", QByteArray::number(qba.size())); + req.setPayload(qba); + req.setCaller(QThread::currentThread()); + req.makeAuthorizedV5(this->getOAuthClient(), this->getOAuthToken()); + + req.onError([=](int errorCode) { + log("[TwitchAccounts::autoModDeny] Error {}", errorCode); + return true; + }); + req.execute(); +} + void TwitchAccount::parseEmotes(const rapidjson::Document &root) { auto emoteData = this->emotes_.access(); diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index 1ea718b73..e5db1749d 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -108,6 +108,10 @@ public: void loadEmotes(); AccessGuard accessEmotes() const; + // Automod actions + void autoModAllow(const QString msgID); + void autoModDeny(const QString msgID); + private: void parseEmotes(const rapidjson::Document &document); void loadEmoteSetData(std::shared_ptr emoteSet); diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 91bfed93b..682a976d7 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/Common.hpp" +#include "controllers/accounts/AccountController.hpp" #include "debug/Benchmark.hpp" #include "debug/Log.hpp" #include "messages/Emote.hpp" @@ -1671,6 +1672,17 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, } break; + case Link::AutoModAllow: + { + getApp()->accounts->twitch.getCurrent()->autoModAllow(link.value); + } + break; + + case Link::AutoModDeny: + { + getApp()->accounts->twitch.getCurrent()->autoModDeny(link.value); + } + default:; } }