// SPDX-FileCopyrightText: 2017 Contributors to Chatterino // // SPDX-License-Identifier: MIT #include "providers/twitch/TwitchAccountManager.hpp" #include "Application.hpp" #include "common/Args.hpp" #include "common/Common.hpp" #include "common/Literals.hpp" #include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchUser.hpp" #include "singletons/Settings.hpp" #include "util/QCompareTransparent.hpp" #include "util/SharedPtrElementLess.hpp" #include namespace { using namespace chatterino; using namespace literals; QString missingScopes(const QJsonArray &scopesArray) { std::set scopes; for (auto s : scopesArray) { scopes.emplace(s.toString()); } QString missingList; for (auto scope : AUTH_SCOPES) { if (!scopes.contains(scope)) { if (!missingList.isEmpty()) { missingList.append(u", "); } missingList.append(scope); } } return missingList; } void checkMissingScopes(const std::shared_ptr &account) { NetworkRequest(u"https://id.twitch.tv/oauth2/validate"_s, NetworkRequestType::Get) .header("Authorization", u"OAuth " % account->getOAuthToken()) .timeout(20000) .onSuccess([account](const auto &res) { auto *app = tryGetApp(); if (!app) { return; } const auto json = res.parseJson(); const auto login = json["login"_L1].toString(); if (!login.isEmpty() && login.compare(account->getUserName(), Qt::CaseInsensitive) != 0) { account->setUserName(login); const std::string basePath = "/accounts/uid" + account->getUserId().toStdString(); pajlada::Settings::Setting::set(basePath + "/username", login); auto &manager = app->getAccounts()->twitch; auto currentUsername = manager.getCurrent()->getUserName(); if (currentUsername.compare(manager.currentUsername.getValue(), Qt::CaseInsensitive) != 0) { manager.currentUsername = currentUsername; } getSettings()->requestSave(); app->getAccounts()->twitch.currentUserNameChanged.invoke(); } auto missing = missingScopes(json["scopes"_L1].toArray()); if (missing.isEmpty()) { return; } auto msg = MessageBuilder::makeMissingScopesMessage(missing); app->getTwitch()->forEachChannel([msg](const auto &chan) { chan->addMessage(msg, MessageContext::Original); }); }) .onError([](const auto &res) { qCWarning(chatterinoTwitch) << "Failed to check for missing scopes:" << res.formatError(); }) .execute(); } } // namespace namespace chatterino { const std::vector AUTH_SCOPES{ u"channel:moderate", // for seeing automod & which moderator banned/unbanned a user (felanbird unbanned weeb123) u"channel:read:redemptions", // for getting the list of channel point redemptions (not currently used) u"chat:edit", // for sending messages in chat u"chat:read", // for viewing messages in chat u"whispers:read", // for viewing recieved whispers // https://dev.twitch.tv/docs/api/reference#start-commercial u"channel:edit:commercial", // for /commercial api // https://dev.twitch.tv/docs/api/reference#create-clip u"clips:edit", // for /clip creation // https://dev.twitch.tv/docs/api/reference#create-stream-marker // https://dev.twitch.tv/docs/api/reference#modify-channel-information u"channel:manage:broadcast", // for creating stream markers with /marker command, and for the /settitle and /setgame commands // https://dev.twitch.tv/docs/api/reference#get-user-block-list u"user:read:blocked_users", // for getting list of blocked users // https://dev.twitch.tv/docs/api/reference#block-user // https://dev.twitch.tv/docs/api/reference#unblock-user u"user:manage:blocked_users", // for blocking/unblocking other users // https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages u"moderator:manage:automod", // for approving/denying automod messages // https://dev.twitch.tv/docs/api/reference#start-a-raid // https://dev.twitch.tv/docs/api/reference#cancel-a-raid u"channel:manage:raids", // for starting/canceling raids // https://dev.twitch.tv/docs/api/reference#create-poll // https://dev.twitch.tv/docs/api/reference#end-poll u"channel:manage:polls", // for creating & ending polls (not currently used) // https://dev.twitch.tv/docs/api/reference#get-polls u"channel:read:polls", // for reading broadcaster poll status (not currently used) // https://dev.twitch.tv/docs/api/reference#create-prediction // https://dev.twitch.tv/docs/api/reference#end-prediction u"channel:manage:predictions", // for creating & ending predictions (not currently used) // https://dev.twitch.tv/docs/api/reference#get-predictions u"channel:read:predictions", // for reading broadcaster prediction status (not currently used) // https://dev.twitch.tv/docs/api/reference#send-chat-announcement u"moderator:manage:announcements", // for /announce api // https://dev.twitch.tv/docs/api/reference#send-whisper u"user:manage:whispers", // for whispers api // https://dev.twitch.tv/docs/api/reference#ban-user // https://dev.twitch.tv/docs/api/reference#unban-user u"moderator:manage:banned_users", // for ban/unban/timeout/untimeout api & channel.moderate eventsub topic // https://dev.twitch.tv/docs/api/reference#delete-chat-messages u"moderator:manage:chat_messages", // for delete message api (/delete, /clear) & channel.moderate eventsub topic // https://dev.twitch.tv/docs/api/reference#update-user-chat-color u"user:manage:chat_color", // for update user color api (/color coral) // https://dev.twitch.tv/docs/api/reference#get-chat-settings u"moderator:manage:chat_settings", // for roomstate api (/followersonly, /uniquechat, /slow) & channel.moderate eventsub topic // https://dev.twitch.tv/docs/api/reference#get-moderators // https://dev.twitch.tv/docs/api/reference#add-channel-moderator // https://dev.twitch.tv/docs/api/reference#remove-channel-vip u"channel:manage:moderators", // for add/remove/view mod api // https://dev.twitch.tv/docs/api/reference#add-channel-vip // https://dev.twitch.tv/docs/api/reference#remove-channel-vip // https://dev.twitch.tv/docs/api/reference#get-vips u"channel:manage:vips", // for add/remove/view vip api // https://dev.twitch.tv/docs/api/reference#get-chatters u"moderator:read:chatters", // for get chatters api // https://dev.twitch.tv/docs/api/reference#get-shield-mode-status // https://dev.twitch.tv/docs/api/reference#update-shield-mode-status u"moderator:manage:shield_mode", // for reading/managing the channel's shield-mode status // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout u"moderator:manage:shoutouts", // for reading/managing the channel's shoutouts (not currently used) // https://dev.twitch.tv/docs/api/reference/#get-moderated-channels u"user:read:moderated_channels", // for reading where the user is modded (not currently used) // https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelchatmessage u"user:read:chat", // for reading chat via eventsub (in progress) // https://dev.twitch.tv/docs/api/reference/#send-chat-message u"user:write:chat", // for sending chat messages via helix (in testing) // https://dev.twitch.tv/docs/api/reference/#get-user-emotes u"user:read:emotes", // for fetching emotes that a user can use via helix // https://dev.twitch.tv/docs/api/reference/#warn-chat-user u"moderator:manage:warnings", // for /warn api & channel.moderate eventsub topic // https://dev.twitch.tv/docs/api/reference/#get-followed-channels u"user:read:follows", // for determining if the current user follows a streamer u"moderator:manage:blocked_terms", // for channel.moderate eventsub topic u"moderator:manage:unban_requests", // for channel.moderate eventsub topic u"moderator:read:moderators", // for channel.moderate eventsub topic u"moderator:read:vips", // for channel.moderate eventsub topic u"moderator:read:suspicious_users", // for channel.suspicious_user.message and channel.suspicious_user.update // https://dev.twitch.tv/docs/api/reference#add-suspicious-status-to-chat-user // https://dev.twitch.tv/docs/api/reference#remove-suspicious-status-from-chat-user u"moderator:manage:suspicious_users", }; TwitchAccountManager::TwitchAccountManager() : accounts(SharedPtrElementLess{}) , anonymousUser_(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) { this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); currentUser->loadBlocks(); currentUser->loadSeventvUserID(); if (!currentUser->isAnon()) { checkMissingScopes(currentUser); } }); // We can safely ignore this signal connection since accounts will always be removed // before TwitchAccountManager std::ignore = this->accounts.itemRemoved.connect([this](const auto &acc) { this->removeUser(acc.item.get()); }); } std::shared_ptr TwitchAccountManager::getCurrent() { if (!this->currentUser_) { return this->anonymousUser_; } return this->currentUser_; } std::vector TwitchAccountManager::getUsernames() const { std::vector userNames; std::lock_guard lock(this->mutex_); for (const auto &user : this->accounts) { userNames.push_back(user->getUserName()); } return userNames; } std::shared_ptr TwitchAccountManager::findUserByUsername( const QString &username) const { std::lock_guard lock(this->mutex_); for (const auto &user : this->accounts) { if (username.compare(user->getUserName(), Qt::CaseInsensitive) == 0) { return user; } } return nullptr; } bool TwitchAccountManager::userExists(const QString &username) const { return this->findUserByUsername(username) != nullptr; } void TwitchAccountManager::reloadUsers() { auto keys = pajlada::Settings::SettingManager::getObjectKeys("/accounts"); UserData userData; bool listUpdated = false; for (const auto &uid : keys) { if (uid == "current") { continue; } auto username = pajlada::Settings::Setting::get( "/accounts/" + uid + "/username"); auto userID = pajlada::Settings::Setting::get("/accounts/" + uid + "/userID"); auto clientID = pajlada::Settings::Setting::get( "/accounts/" + uid + "/clientID"); auto oauthToken = pajlada::Settings::Setting::get( "/accounts/" + uid + "/oauthToken"); if (username.isEmpty() || userID.isEmpty() || clientID.isEmpty() || oauthToken.isEmpty()) { continue; } userData.username = username.trimmed(); userData.userID = userID.trimmed(); userData.clientID = clientID.trimmed(); userData.oauthToken = oauthToken.trimmed(); switch (this->addUser(userData)) { case AddUserResponse::UserAlreadyExists: { qCDebug(chatterinoTwitch) << "User" << userData.username << "already exists"; // Do nothing } break; case AddUserResponse::UserValuesUpdated: { qCDebug(chatterinoTwitch) << "User" << userData.username << "already exists, and values updated!"; if (userData.username == this->getCurrent()->getUserName()) { qCDebug(chatterinoTwitch) << "It was the current user, so we need to " "reconnect stuff!"; this->currentUserChanged(); } } break; case AddUserResponse::UserAdded: { qCDebug(chatterinoTwitch) << "Added user" << userData.username; listUpdated = true; } break; } } if (listUpdated) { this->userListUpdated.invoke(); } } void TwitchAccountManager::load() { if (getApp()->getArgs().initialLogin.has_value()) { this->currentUsername = getApp()->getArgs().initialLogin.value(); } this->reloadUsers(); this->currentUsername.connect([this](const QString &newUsername) { auto user = this->findUserByUsername(newUsername); this->currentUserAboutToChange.invoke(this->currentUser_, user); if (user) { qCDebug(chatterinoTwitch) << "Twitch user updated to" << newUsername; getHelix()->update(user->getOAuthClient(), user->getOAuthToken()); this->currentUser_ = user; } else { qCDebug(chatterinoTwitch) << "Twitch user updated to anonymous"; this->currentUser_ = this->anonymousUser_; } this->currentUserChanged(); this->currentUser_->reloadEmotes(); }); } bool TwitchAccountManager::isLoggedIn() const { if (!this->currentUser_) { return false; } // Once `TwitchAccount` class has a way to check, we should also return // false if the credentials are incorrect return !this->currentUser_->isAnon(); } bool TwitchAccountManager::removeUser(TwitchAccount *account) { static const QString accountFormat("/accounts/uid%1"); auto userID(account->getUserId()); if (!userID.isEmpty()) { pajlada::Settings::SettingManager::removeSetting( accountFormat.arg(userID).toStdString()); } if (account->getUserName() == this->currentUsername) { // The user that was removed is the current user, log into the anonymous // user this->currentUsername = ""; } this->userListUpdated.invoke(); return true; } TwitchAccountManager::AddUserResponse TwitchAccountManager::addUser( const TwitchAccountManager::UserData &userData) { auto previousUser = this->findUserByUsername(userData.username); if (previousUser) { bool userUpdated = false; if (previousUser->setOAuthClient(userData.clientID)) { userUpdated = true; } if (previousUser->setOAuthToken(userData.oauthToken)) { userUpdated = true; } if (userUpdated) { return AddUserResponse::UserValuesUpdated; } else { return AddUserResponse::UserAlreadyExists; } } auto newUser = std::make_shared(userData.username, userData.oauthToken, userData.clientID, userData.userID); // std::lock_guard lock(this->mutex); this->accounts.insert(newUser); return AddUserResponse::UserAdded; } } // namespace chatterino