diff --git a/dChatFilter/dChatFilter.h b/dChatFilter/dChatFilter.h index caa5c1049..cd8dac353 100644 --- a/dChatFilter/dChatFilter.h +++ b/dChatFilter/dChatFilter.h @@ -29,6 +29,8 @@ public: bool ReadWordlistDCF(const std::string& filepath, bool allowList); void ExportWordlistToDCF(const std::string& filepath, bool allowList); std::set> IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList = true); + // Whether a deny list is loaded (without one, IsSentenceOkay(..., false) refuses every message) + bool HasDenyList() const { return !m_DeniedWords.empty(); } /** * Load the words staff added on the dashboard (chat_filter_words) again, replacing the ones loaded before. diff --git a/dChatServer/CMakeLists.txt b/dChatServer/CMakeLists.txt index 83273058f..5f196a933 100644 --- a/dChatServer/CMakeLists.txt +++ b/dChatServer/CMakeLists.txt @@ -2,6 +2,7 @@ set(DCHATSERVER_SOURCES "ChatIgnoreList.cpp" "ChatPacketHandler.cpp" "ChatJSONUtils.cpp" + "ChatGuilds.cpp" "ChatWeb.cpp" "GuildManager.cpp" "PlayerContainer.cpp" diff --git a/dChatServer/ChatGuilds.cpp b/dChatServer/ChatGuilds.cpp new file mode 100644 index 000000000..b61a93e2e --- /dev/null +++ b/dChatServer/ChatGuilds.cpp @@ -0,0 +1,82 @@ +#include "ChatGuilds.h" + +#include +#include +#include + +#include "ChatPacketHandler.h" +#include "ChatPackets.h" +#include "Database.h" +#include "dChatFilter.h" +#include "dConfig.h" +#include "dServer.h" +#include "eGameMasterLevel.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "GuildManager.h" +#include "PlayerContainer.h" + +namespace { + std::unique_ptr g_Guilds; + + std::string Lower(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return text; + } + + std::optional Online(const PlayerData& player) { + if (!player) return std::nullopt; + return GuildManager::OnlinePlayer{ player.playerID, player.playerName, player.zoneID }; + } +} + +void ChatGuilds::Initialize() { + GuildManager::Settings settings; + settings.maxMembers = GeneralUtils::TryParse(Game::config->GetValue("guild_max_members")).value_or(settings.maxMembers); + settings.inviteTimeout = GeneralUtils::TryParse(Game::config->GetValue("guild_invite_timeout")).value_or(settings.inviteTimeout); + + GuildManager::Hooks hooks; + hooks.findOnline = [](const LWOOBJID id) { return Online(Game::playerContainer.GetPlayerData(id)); }; + hooks.findOnlineByName = [](const std::string& name) -> std::optional { + const auto wanted = Lower(name); + for (const auto& [id, player] : Game::playerContainer.GetAllPlayers()) { + if (player && Lower(player.playerName) == wanted) return Online(player); + } + return std::nullopt; + }; + hooks.characterExists = [](const std::string& name) { return Database::Get()->GetCharacterInfo(name).has_value(); }; + hooks.sendToPlayer = [](const LWOOBJID id, const LUBitStream& msg) { + const auto& player = Game::playerContainer.GetPlayerData(id); + if (player) ChatPacketHandler::SendRouted(id, player.worldServerSysAddr, msg); + }; + hooks.sendToWorldOf = [](const LWOOBJID id, const LUBitStream& msg) { + const auto& player = Game::playerContainer.GetPlayerData(id); + if (!player) return; + RakNet::BitStream bitStream; + msg.WritePacket(bitStream); + Game::server->Send(bitStream, player.worldServerSysAddr, false); + }; + hooks.notify = [](const LWOOBJID id, const std::string& text) { + const auto& player = Game::playerContainer.GetPlayerData(id); + if (!player) return; + ChatPackets::Client::GeneralChatMessage chatMessage; + chatMessage.chatChannel = 4; + chatMessage.senderName = LUWString("", 33); + chatMessage.message = GeneralUtils::UTF8ToUTF16(text); + ChatPacketHandler::SendRouted(id, player.worldServerSysAddr, chatMessage); + }; + hooks.checkName = [](const std::string& name) { + if (!Game::chatFilter) return GuildManager::NameCheck::PENDING; + // The deny list refuses a name; one the allow list doesn't cover waits for a moderator (as pet names do) + if (Game::chatFilter->HasDenyList() && !Game::chatFilter->IsSentenceOkay(name, eGameMasterLevel::CIVILIAN, false).empty()) return GuildManager::NameCheck::DENIED; + if (!Game::chatFilter->IsSentenceOkay(name, eGameMasterLevel::CIVILIAN).empty()) return GuildManager::NameCheck::PENDING; + return GuildManager::NameCheck::APPROVED; + }; + hooks.now = [] { return static_cast(std::time(nullptr)); }; + + g_Guilds = std::make_unique(*Database::Get(), std::move(hooks), settings); +} + +GuildManager& ChatGuilds::Get() { + return *g_Guilds; +} diff --git a/dChatServer/ChatGuilds.h b/dChatServer/ChatGuilds.h new file mode 100644 index 000000000..2b780b74c --- /dev/null +++ b/dChatServer/ChatGuilds.h @@ -0,0 +1,13 @@ +#ifndef CHATGUILDS_H +#define CHATGUILDS_H + +class GuildManager; + +// The chat server's GuildManager, connected to its players, database, chat filter and worlds (docs/Guilds.md) +namespace ChatGuilds { + // Reads guild_max_members and guild_invite_timeout; call once after the database, config and chat filter are up + void Initialize(); + GuildManager& Get(); +} + +#endif // CHATGUILDS_H diff --git a/dChatServer/ChatPacketHandler.cpp b/dChatServer/ChatPacketHandler.cpp index c45043826..3bee04195 100644 --- a/dChatServer/ChatPacketHandler.cpp +++ b/dChatServer/ChatPacketHandler.cpp @@ -1,4 +1,6 @@ #include "ChatPacketHandler.h" +#include "ChatGuilds.h" +#include "GuildManager.h" #include "PlayerContainer.h" #include "Database.h" #include @@ -470,6 +472,17 @@ void ChatPacketHandler::HandleChatMessage(const ChatPackets::GeneralChatMessage& } break; } + case eChatChannel::GUILD: { + // Sent by the world for /g, which is what the client's guild chat tab sends + const auto members = ChatGuilds::Get().OnlineGuildmates(playerID); + if (members.empty()) return; + LogChat(sender, "guild", message.GetAsString()); + for (const auto memberId : members) { + const auto& member = Game::playerContainer.GetPlayerData(memberId); + if (member) SendPrivateChatMessage(sender, member, member, message, eChatChannel::GUILD, eChatMessageResponseCode::SENT); + } + break; + } default: LOG("Unhandled Chat channel [%s]", StringifiedEnum::ToString(channel).data()); break; diff --git a/dChatServer/ChatServer.cpp b/dChatServer/ChatServer.cpp index a37ac06c2..a1ed99c83 100644 --- a/dChatServer/ChatServer.cpp +++ b/dChatServer/ChatServer.cpp @@ -24,6 +24,8 @@ #include "ChatIgnoreList.h" #include "StringifiedEnum.h" #include "TeamContainer.h" +#include "ChatGuilds.h" +#include "GuildManager.h" #include "PacketDispatcher.h" #include "ChatPackets.h" #include "master/LiveUpdate.h" @@ -145,6 +147,7 @@ int main(int argc, char** argv) { Game::randomEngine = std::mt19937(time(0)); Game::playerContainer.Initialize(); + ChatGuilds::Initialize(); //Run it until server gets a kill message from Master: auto t = std::chrono::high_resolution_clock::now(); @@ -269,6 +272,15 @@ namespace { announcement.WritePacket(bitStream); Game::server->Send(bitStream, sysAddr, true); // send to everyone except origin }); + // Guilds (docs/Guilds.md): the first four come from the client through its world, the rest from worlds + handlers.On(Chat::GUILD_INVITE, [](const GuildInvite& invite, const SystemAddress&) { ChatGuilds::Get().Invite(invite.playerID, invite.invitedPlayer.GetAsString()); }); + handlers.On(Chat::GUILD_INVITE_RESPONSE, [](const GuildInviteResponse& response, const SystemAddress&) { ChatGuilds::Get().AnswerInvite(response.playerID, response.declined != 0); }); + handlers.On(Chat::GUILD_LEAVE, [](const GuildLeave& leave, const SystemAddress&) { ChatGuilds::Get().Leave(leave.playerID); }); + handlers.On(Chat::GUILD_GET_ALL, [](const GuildGetAll& request, const SystemAddress&) { ChatGuilds::Get().GetAll(request.playerID); }); + handlers.On(Chat::GUILD_CREATE, [](const GuildCreate& create, const SystemAddress&) { ChatGuilds::Get().Create(create.playerID, create.guildName.string); }); + handlers.On(Chat::GUILD_KICK, [](const GuildKick& kick, const SystemAddress&) { ChatGuilds::Get().Kick(kick.playerID, kick.kickedPlayer.GetAsString()); }); + handlers.On(Chat::GUILD_SET_RANK, [](const GuildSetRank& rank, const SystemAddress&) { ChatGuilds::Get().SetRank(rank.playerID, rank.targetPlayer.GetAsString(), static_cast(rank.rank)); }); + handlers.On(Chat::GUILD_DISBAND, [](const GuildDisband& disband, const SystemAddress&) { ChatGuilds::Get().Disband(disband.playerID); }); handlers.On(Chat::UNEXPECTED_DISCONNECT, [](const UnexpectedDisconnect& notify, const SystemAddress& sysAddr) { Game::playerContainer.ScheduleRemovePlayer(notify, sysAddr); }); handlers.On(Chat::WHO, ChatPacketHandler::HandleWho); handlers.On(Chat::SHOW_ALL, ChatPacketHandler::HandleShowAll); @@ -308,6 +320,8 @@ namespace { Game::chatFilter->ReloadCustomWords(); LOG("Reloaded the chat filter's words (changed on the dashboard)"); } + // A guild changed on the dashboard: tell its online members + if (request.action == ePlayerAction::GUILD_CHANGED) ChatGuilds::Get().GuildChanged(request.targetId); }); return handlers; }(); diff --git a/dChatServer/PlayerContainer.cpp b/dChatServer/PlayerContainer.cpp index a83ba9b6e..7da79bdbe 100644 --- a/dChatServer/PlayerContainer.cpp +++ b/dChatServer/PlayerContainer.cpp @@ -14,6 +14,8 @@ #include "MessageType/Chat.h" #include "ChatWeb.h" #include "TeamContainer.h" +#include "ChatGuilds.h" +#include "GuildManager.h" void PlayerContainer::Initialize() { m_MaxNumberOfBestFriends = @@ -52,6 +54,8 @@ void PlayerContainer::InsertPlayer(const ChatPackets::LoginSessionNotify& notify ChatPacketHandler::LoadFriends(data); m_PlayersToRemove.erase(playerId); LOG("Took over user: %s (%llu), zone: %i", data.playerName.c_str(), data.playerID, data.zoneID.GetMapID()); + // Guildmates' clients only get the zone again (no "logged in" line) + ChatGuilds::Get().PlayerOnline(playerId, false); return; } @@ -75,6 +79,7 @@ void PlayerContainer::InsertPlayer(const ChatPackets::LoginSessionNotify& notify Database::Get()->UpdateActivityLog(data.playerID, isLogin ? eActivityType::PlayerLoggedIn : eActivityType::PlayerChangedZone, data.zoneID.GetMapID()); m_PlayersToRemove.erase(playerId); + ChatGuilds::Get().PlayerOnline(playerId, isLogin); } void PlayerContainer::ScheduleRemovePlayer(const ChatPackets::UnexpectedDisconnect& notify, const SystemAddress& sysAddr) { @@ -117,6 +122,8 @@ void PlayerContainer::RemovePlayer(const LWOOBJID playerID) { TeamContainer::RemoveMember(team, playerID, false, false, true); } + ChatGuilds::Get().PlayerOffline(playerID); + ChatWeb::SendWSPlayerUpdate(player, eActivityType::PlayerLoggedOut); m_PlayerCount--; diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index b121068a3..1f9c731ca 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -269,6 +269,10 @@ namespace { c.Add(Int(CHAT, "max_number_of_best_friends", "Best friends", "5 is live accurate.", "5", 0, 1000, true)); c.Add(Int(CHAT, "max_ignores", "Ignore list size", "", "32", 0, 1000)); + c.AddSection("Guilds", "docs/Guilds.md. Players also need the client's FeatureGating row \"guilds\"."); + c.Add(Int(CHAT, "guild_max_members", "Guild members", "The client has no limit of its own.", "100", 1, 1000, true)); + c.Add(Int(CHAT, "guild_invite_timeout", "Seconds to answer a guild invite", "", "600", 10, 86400, true)); + c.AddSection("Dashboard sign-in"); c.Add(Int(DASHBOARD, "min_dashboard_gm_level", "Lowest GM level allowed in", "0 lets every player see their own account.", "0", 0, 9)); c.Add(Int(DASHBOARD, "require_2fa_gm_level", "Two-factor login required from GM level", "Staff at or above this level must use an authenticator app. 0 turns it off.", "0", 0, 9)); diff --git a/dGame/dUtilities/DashboardActions.cpp b/dGame/dUtilities/DashboardActions.cpp index cf32bcb7d..8e13e4702 100644 --- a/dGame/dUtilities/DashboardActions.cpp +++ b/dGame/dUtilities/DashboardActions.cpp @@ -305,6 +305,9 @@ uint32_t DashboardActions::Apply(const PlayerActionRequest& request) { return LiveEvents::Reload(); case ePlayerAction::RELOAD_CONTRABAND: return Contraband::Reload(); + case ePlayerAction::GUILD_CHANGED: + // The chat server's (master doesn't send it to worlds) + return 0; } return 0; } diff --git a/dMasterServer/MasterServer.cpp b/dMasterServer/MasterServer.cpp index 1ab85e51f..03c169321 100644 --- a/dMasterServer/MasterServer.cpp +++ b/dMasterServer/MasterServer.cpp @@ -941,6 +941,16 @@ namespace { pending.action = request.action; pending.deadline = std::chrono::steady_clock::now() + PLAYER_ACTION_TIMEOUT; + // Guilds belong to the chat server: it tells the players (docs/Guilds.md) + if (request.action == ePlayerAction::GUILD_CHANGED) { + const bool chatConnected = chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS; + if (chatConnected) MasterPackets::SendTo(chatServerMasterPeerSysAddr, request); + pending.affected = chatConnected ? 1 : 0; + g_PendingPlayerActions[request.requestId] = std::move(pending); + FinishPlayerAction(request.requestId, false); + return; + } + for (const auto& instance : Game::im->GetInstances()) { if (!instance || !instance->GetIsReady() || instance->GetIsShuttingDown()) continue; MasterPackets::SendTo(instance->GetSysAddr(), request); diff --git a/dNet/master/PlayerAction.h b/dNet/master/PlayerAction.h index 96b484f69..6d0edf608 100644 --- a/dNet/master/PlayerAction.h +++ b/dNet/master/PlayerAction.h @@ -36,6 +36,7 @@ enum class ePlayerAction : uint8_t { MISSION_ACCEPT, // as /addmission (prerequisites are skipped) RELOAD_LIVE_OPS, // load the running live events and open challenges again (every world, answering 1 each; see LiveEvents.h) RELOAD_CONTRABAND, // load the contraband list again (every world, answering 1 each; see Contraband.h) + GUILD_CHANGED, // guild targetId changed in the database (renamed, name moderated, disbanded, member removed): only the chat server acts on it, answering 1 through master when it is connected }; struct PlayerActionRequest : public LUBitStream { diff --git a/resources/chatconfig.ini b/resources/chatconfig.ini index 748a21c79..76d2315ef 100644 --- a/resources/chatconfig.ini +++ b/resources/chatconfig.ini @@ -7,6 +7,11 @@ max_number_of_best_friends=5 # going over 50 will be allowed in some secnarios, but proper handling will require client modding max_number_of_friends=50 +# Guilds (docs/Guilds.md). The client has no limit of its own; live's is unknown. +guild_max_members=100 +# Seconds a guild invite can be answered +guild_invite_timeout=600 + web_server_enabled=0 # Unused for now