From 61921cfb62403d3acd3c6c5faf09f42cd9e8b068 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 14 May 2025 22:38:38 -0500 Subject: [PATCH] feat: refactor web server to be generic and add websockets framework (#1786) * Break out changes into a smaller subset * NL@EOF * fix windows bs add player ws updates add websocket docs * tested everything to make sure it works * Address Feedback --- CMakeLists.txt | 3 + dChatFilter/dChatFilter.cpp | 10 +- dChatFilter/dChatFilter.h | 2 +- dChatServer/CMakeLists.txt | 10 +- .../{JSONUtils.cpp => ChatJSONUtils.cpp} | 15 +- dChatServer/{JSONUtils.h => ChatJSONUtils.h} | 11 +- dChatServer/ChatServer.cpp | 21 +- dChatServer/ChatWeb.cpp | 133 ++++++++ dChatServer/ChatWeb.h | 19 ++ dChatServer/ChatWebAPI.cpp | 197 ------------ dChatServer/ChatWebAPI.h | 36 --- dChatServer/PlayerContainer.cpp | 6 +- dCommon/CMakeLists.txt | 1 + dCommon/JSONUtils.cpp | 17 + dCommon/JSONUtils.h | 11 + dCommon/dEnums/eHTTPMethod.h | 2 + dDatabase/GameDatabase/ITables/IActivityLog.h | 1 + dNet/BitStreamUtils.h | 1 + dNet/ChatPackets.cpp | 15 +- dNet/ChatPackets.h | 6 +- dNet/WorldPackets.cpp | 2 +- dNet/WorldPackets.h | 2 +- dWeb/CMakeLists.txt | 7 + dWeb/Web.cpp | 301 ++++++++++++++++++ dWeb/Web.h | 82 +++++ dWorldServer/WorldServer.cpp | 2 +- docs/ChatWSAPI.yaml | 131 ++++++++ 27 files changed, 753 insertions(+), 291 deletions(-) rename dChatServer/{JSONUtils.cpp => ChatJSONUtils.cpp} (74%) rename dChatServer/{JSONUtils.h => ChatJSONUtils.h} (66%) create mode 100644 dChatServer/ChatWeb.cpp create mode 100644 dChatServer/ChatWeb.h delete mode 100644 dChatServer/ChatWebAPI.cpp delete mode 100644 dChatServer/ChatWebAPI.h create mode 100644 dCommon/JSONUtils.cpp create mode 100644 dCommon/JSONUtils.h create mode 100644 dWeb/CMakeLists.txt create mode 100644 dWeb/Web.cpp create mode 100644 dWeb/Web.h create mode 100644 docs/ChatWSAPI.yaml diff --git a/CMakeLists.txt b/CMakeLists.txt index feeb7d71..e4018e01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -235,6 +235,8 @@ include_directories( "dNet" + "dWeb" + "tests" "tests/dCommonTests" "tests/dGameTests" @@ -301,6 +303,7 @@ add_subdirectory(dZoneManager) add_subdirectory(dNavigation) add_subdirectory(dPhysics) add_subdirectory(dServer) +add_subdirectory(dWeb) # Create a list of common libraries shared between all binaries set(COMMON_LIBRARIES "dCommon" "dDatabase" "dNet" "raknet" "magic_enum") diff --git a/dChatFilter/dChatFilter.cpp b/dChatFilter/dChatFilter.cpp index 844e3411..49fe88bf 100644 --- a/dChatFilter/dChatFilter.cpp +++ b/dChatFilter/dChatFilter.cpp @@ -105,7 +105,7 @@ void dChatFilter::ExportWordlistToDCF(const std::string& filepath, bool allowLis } } -std::vector> dChatFilter::IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList) { +std::set> dChatFilter::IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList) { if (gmLevel > eGameMasterLevel::FORUM_MODERATOR) return { }; //If anything but a forum mod, return true. if (message.empty()) return { }; if (!allowList && m_DeniedWords.empty()) return { { 0, message.length() } }; @@ -114,7 +114,7 @@ std::vector> dChatFilter::IsSentenceOkay(const std:: std::string segment; std::regex reg("(!*|\\?*|\\;*|\\.*|\\,*)"); - std::vector> listOfBadSegments = std::vector>(); + std::set> listOfBadSegments; uint32_t position = 0; @@ -127,17 +127,17 @@ std::vector> dChatFilter::IsSentenceOkay(const std:: size_t hash = CalculateHash(segment); if (std::find(m_UserUnapprovedWordCache.begin(), m_UserUnapprovedWordCache.end(), hash) != m_UserUnapprovedWordCache.end() && allowList) { - listOfBadSegments.emplace_back(position, originalSegment.length()); + listOfBadSegments.emplace(position, originalSegment.length()); } if (std::find(m_ApprovedWords.begin(), m_ApprovedWords.end(), hash) == m_ApprovedWords.end() && allowList) { m_UserUnapprovedWordCache.push_back(hash); - listOfBadSegments.emplace_back(position, originalSegment.length()); + listOfBadSegments.emplace(position, originalSegment.length()); } if (std::find(m_DeniedWords.begin(), m_DeniedWords.end(), hash) != m_DeniedWords.end() && !allowList) { m_UserUnapprovedWordCache.push_back(hash); - listOfBadSegments.emplace_back(position, originalSegment.length()); + listOfBadSegments.emplace(position, originalSegment.length()); } position += originalSegment.length() + 1; diff --git a/dChatFilter/dChatFilter.h b/dChatFilter/dChatFilter.h index 0f1e49ba..ce2fc5f0 100644 --- a/dChatFilter/dChatFilter.h +++ b/dChatFilter/dChatFilter.h @@ -24,7 +24,7 @@ public: void ReadWordlistPlaintext(const std::string& filepath, bool allowList); bool ReadWordlistDCF(const std::string& filepath, bool allowList); void ExportWordlistToDCF(const std::string& filepath, bool allowList); - std::vector> IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList = true); + std::set> IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList = true); private: bool m_DontGenerateDCF; diff --git a/dChatServer/CMakeLists.txt b/dChatServer/CMakeLists.txt index 8554ac19..4d70dd15 100644 --- a/dChatServer/CMakeLists.txt +++ b/dChatServer/CMakeLists.txt @@ -1,19 +1,19 @@ set(DCHATSERVER_SOURCES "ChatIgnoreList.cpp" "ChatPacketHandler.cpp" - "ChatWebAPI.cpp" - "JSONUtils.cpp" + "ChatJSONUtils.cpp" + "ChatWeb.cpp" "PlayerContainer.cpp" "TeamContainer.cpp" ) add_executable(ChatServer "ChatServer.cpp") -target_include_directories(ChatServer PRIVATE "${PROJECT_SOURCE_DIR}/dChatFilter") +target_include_directories(ChatServer PRIVATE "${PROJECT_SOURCE_DIR}/dChatFilter" "${PROJECT_SOURCE_DIR}/dWeb") add_compile_definitions(ChatServer PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"") add_library(dChatServer ${DCHATSERVER_SOURCES}) -target_include_directories(dChatServer PRIVATE "${PROJECT_SOURCE_DIR}/dServer") +target_include_directories(dChatServer PRIVATE "${PROJECT_SOURCE_DIR}/dServer" "${PROJECT_SOURCE_DIR}/dChatFilter") target_link_libraries(dChatServer ${COMMON_LIBRARIES} dChatFilter) -target_link_libraries(ChatServer ${COMMON_LIBRARIES} dChatFilter dChatServer dServer mongoose) +target_link_libraries(ChatServer ${COMMON_LIBRARIES} dChatFilter dChatServer dServer mongoose dWeb) diff --git a/dChatServer/JSONUtils.cpp b/dChatServer/ChatJSONUtils.cpp similarity index 74% rename from dChatServer/JSONUtils.cpp rename to dChatServer/ChatJSONUtils.cpp index 116961fb..a38e3425 100644 --- a/dChatServer/JSONUtils.cpp +++ b/dChatServer/ChatJSONUtils.cpp @@ -1,4 +1,4 @@ -#include "JSONUtils.h" +#include "ChatJSONUtils.h" #include "json.hpp" @@ -47,16 +47,3 @@ void TeamContainer::to_json(json& data, const TeamContainer::Data& teamContainer data.push_back(*teamData); } } - -std::string JSONUtils::CheckRequiredData(const json& data, const std::vector& requiredData) { - json check; - check["error"] = json::array(); - for (const auto& required : requiredData) { - if (!data.contains(required)) { - check["error"].push_back("Missing Parameter: " + required); - } else if (data[required] == "") { - check["error"].push_back("Empty Parameter: " + required); - } - } - return check["error"].empty() ? "" : check.dump(); -} diff --git a/dChatServer/JSONUtils.h b/dChatServer/ChatJSONUtils.h similarity index 66% rename from dChatServer/JSONUtils.h rename to dChatServer/ChatJSONUtils.h index ccdd5359..e3120bb3 100644 --- a/dChatServer/JSONUtils.h +++ b/dChatServer/ChatJSONUtils.h @@ -1,5 +1,5 @@ -#ifndef __JSONUTILS_H__ -#define __JSONUTILS_H__ +#ifndef __CHATJSONUTILS_H__ +#define __CHATJSONUTILS_H__ #include "json_fwd.hpp" #include "PlayerContainer.h" @@ -15,9 +15,4 @@ namespace TeamContainer { void to_json(nlohmann::json& data, const TeamContainer::Data& teamData); }; -namespace JSONUtils { - // check required data for reqeust - std::string CheckRequiredData(const nlohmann::json& data, const std::vector& requiredData); -} - -#endif // __JSONUTILS_H__ +#endif // !__CHATJSONUTILS_H__ diff --git a/dChatServer/ChatServer.cpp b/dChatServer/ChatServer.cpp index 5c673e27..abc7d779 100644 --- a/dChatServer/ChatServer.cpp +++ b/dChatServer/ChatServer.cpp @@ -29,7 +29,7 @@ #include "RakNetDefines.h" #include "MessageIdentifiers.h" -#include "ChatWebAPI.h" +#include "ChatWeb.h" namespace Game { Logger* logger = nullptr; @@ -93,17 +93,18 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } - // seyup the chat api web server - bool web_server_enabled = Game::config->GetValue("web_server_enabled") == "1"; - ChatWebAPI chatwebapi; - if (web_server_enabled && !chatwebapi.Startup()) { - // if we want the web api and it fails to start, exit + // setup the chat api web server + const uint32_t web_server_port = GeneralUtils::TryParse(Game::config->GetValue("web_server_port")).value_or(2005); + if (Game::config->GetValue("web_server_enabled") == "1" && !Game::web.Startup("localhost", web_server_port)) { + // if we want the web server and it fails to start, exit LOG("Failed to start web server, shutting down."); Database::Destroy("ChatServer"); delete Game::logger; delete Game::config; return EXIT_FAILURE; - }; + } + + if (Game::web.IsEnabled()) ChatWeb::RegisterRoutes(); //Find out the master's IP: std::string masterIP; @@ -167,10 +168,8 @@ int main(int argc, char** argv) { packet = nullptr; } - //Check and handle web requests: - if (web_server_enabled) { - chatwebapi.ReceiveRequests(); - } + // Check and handle web requests: + if (Game::web.IsEnabled()) Game::web.ReceiveRequests(); //Push our log every 30s: if (framesSinceLastFlush >= logFlushTime) { diff --git a/dChatServer/ChatWeb.cpp b/dChatServer/ChatWeb.cpp new file mode 100644 index 00000000..72af5e84 --- /dev/null +++ b/dChatServer/ChatWeb.cpp @@ -0,0 +1,133 @@ +#include "ChatWeb.h" + +#include "Logger.h" +#include "Game.h" +#include "json.hpp" +#include "dCommonVars.h" +#include "MessageType/Chat.h" +#include "dServer.h" +#include "dConfig.h" +#include "PlayerContainer.h" +#include "GeneralUtils.h" +#include "eHTTPMethod.h" +#include "magic_enum.hpp" +#include "ChatPackets.h" +#include "StringifiedEnum.h" +#include "Database.h" +#include "ChatJSONUtils.h" +#include "JSONUtils.h" +#include "eGameMasterLevel.h" +#include "dChatFilter.h" +#include "TeamContainer.h" + +using json = nlohmann::json; + +void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) { + const json data = Game::playerContainer; + reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK; + reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump(); +} + +void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) { + const json data = TeamContainer::GetTeamContainer(); + reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK; + reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump(); +} + +void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) { + auto data = GeneralUtils::TryParse(body); + if (!data) { + reply.status = eHTTPStatusCode::BAD_REQUEST; + reply.message = "{\"error\":\"Invalid JSON\"}"; + return; + } + + const auto& good_data = data.value(); + auto check = JSONUtils::CheckRequiredData(good_data, { "title", "message" }); + if (!check.empty()) { + reply.status = eHTTPStatusCode::BAD_REQUEST; + reply.message = check; + } else { + + ChatPackets::Announcement announcement; + announcement.title = good_data["title"]; + announcement.message = good_data["message"]; + announcement.Broadcast(); + + reply.status = eHTTPStatusCode::OK; + reply.message = "{\"status\":\"Announcement Sent\"}"; + } +} + +void HandleWSChat(mg_connection* connection, json data) { + auto check = JSONUtils::CheckRequiredData(data, { "user", "message", "gmlevel", "zone" }); + if (!check.empty()) { + LOG_DEBUG("Received invalid websocket message: %s", check.c_str()); + } else { + const auto user = data["user"].get(); + const auto message = data["message"].get(); + const auto gmlevel = GeneralUtils::TryParse(data["gmlevel"].get()).value_or(eGameMasterLevel::CIVILIAN); + const auto zone = data["zone"].get(); + + const auto filter_check = Game::chatFilter->IsSentenceOkay(message, gmlevel); + if (!filter_check.empty()) { + LOG_DEBUG("Chat message \"%s\" from %s was not allowed", message.c_str(), user.c_str()); + data["error"] = "Chat message blocked by filter"; + data["filtered"] = json::array(); + for (const auto& [start, len] : filter_check) { + data["filtered"].push_back(message.substr(start, len)); + } + mg_ws_send(connection, data.dump().c_str(), data.dump().size(), WEBSOCKET_OP_TEXT); + return; + } + LOG("%s: %s", user.c_str(), message.c_str()); + + // TODO: Implement chat message handling from websocket message + + } +} + +namespace ChatWeb { + void RegisterRoutes() { + + // REST API v1 routes + + std::string v1_route = "/api/v1/"; + Game::web.RegisterHTTPRoute({ + .path = v1_route + "players", + .method = eHTTPMethod::GET, + .handle = HandleHTTPPlayersRequest + }); + + Game::web.RegisterHTTPRoute({ + .path = v1_route + "teams", + .method = eHTTPMethod::GET, + .handle = HandleHTTPTeamsRequest + }); + + Game::web.RegisterHTTPRoute({ + .path = v1_route + "announce", + .method = eHTTPMethod::POST, + .handle = HandleHTTPAnnounceRequest + }); + + // WebSocket Events Handlers + + // Game::web.RegisterWSEvent({ + // .name = "chat", + // .handle = HandleWSChat + // }); + + // WebSocket subscriptions + + Game::web.RegisterWSSubscription("player"); + } + + void SendWSPlayerUpdate(const PlayerData& player, eActivityType activityType) { + json data; + data["player_data"] = player; + data["update_type"] = magic_enum::enum_name(activityType); + Game::web.SendWSMessage("player", data); + } +} + diff --git a/dChatServer/ChatWeb.h b/dChatServer/ChatWeb.h new file mode 100644 index 00000000..dc389f58 --- /dev/null +++ b/dChatServer/ChatWeb.h @@ -0,0 +1,19 @@ +#ifndef __CHATWEB_H__ +#define __CHATWEB_H__ + +#include +#include + +#include "Web.h" +#include "PlayerContainer.h" +#include "IActivityLog.h" +#include "ChatPacketHandler.h" + +namespace ChatWeb { + void RegisterRoutes(); + void SendWSPlayerUpdate(const PlayerData& player, eActivityType activityType); +}; + + +#endif // __CHATWEB_H__ + diff --git a/dChatServer/ChatWebAPI.cpp b/dChatServer/ChatWebAPI.cpp deleted file mode 100644 index c083c183..00000000 --- a/dChatServer/ChatWebAPI.cpp +++ /dev/null @@ -1,197 +0,0 @@ -#include "ChatWebAPI.h" - -#include "Logger.h" -#include "Game.h" -#include "json.hpp" -#include "dCommonVars.h" -#include "MessageType/Chat.h" -#include "dServer.h" -#include "dConfig.h" -#include "PlayerContainer.h" -#include "JSONUtils.h" -#include "GeneralUtils.h" -#include "eHTTPMethod.h" -#include "magic_enum.hpp" -#include "ChatPackets.h" -#include "StringifiedEnum.h" -#include "Database.h" - -#ifdef DARKFLAME_PLATFORM_WIN32 -#pragma push_macro("DELETE") -#undef DELETE -#endif - -using json = nlohmann::json; - -typedef struct mg_connection mg_connection; -typedef struct mg_http_message mg_http_message; - -namespace { - const char* json_content_type = "Content-Type: application/json\r\n"; - std::map, WebAPIHTTPRoute> Routes{}; -} - -bool ValidateAuthentication(const mg_http_message* http_msg) { - // TO DO: This is just a placeholder for now - // use tokens or something at a later point if we want to implement authentication - // bit using the listen bind address to limit external access is good enough to start with - return true; -} - -bool ValidateJSON(std::optional data, HTTPReply& reply) { - if (!data) { - reply.status = eHTTPStatusCode::BAD_REQUEST; - reply.message = "{\"error\":\"Invalid JSON\"}"; - return false; - } - return true; -} - -void HandlePlayersRequest(HTTPReply& reply, std::string body) { - const json data = Game::playerContainer; - reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK; - reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump(); -} - -void HandleTeamsRequest(HTTPReply& reply, std::string body) { - const json data = TeamContainer::GetTeamContainer(); - reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK; - reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump(); -} - -void HandleAnnounceRequest(HTTPReply& reply, std::string body) { - auto data = GeneralUtils::TryParse(body); - if (!ValidateJSON(data, reply)) return; - - const auto& good_data = data.value(); - auto check = JSONUtils::CheckRequiredData(good_data, { "title", "message" }); - if (!check.empty()) { - reply.status = eHTTPStatusCode::BAD_REQUEST; - reply.message = check; - } else { - - ChatPackets::Announcement announcement; - announcement.title = good_data["title"]; - announcement.message = good_data["message"]; - announcement.Send(); - - reply.status = eHTTPStatusCode::OK; - reply.message = "{\"status\":\"Announcement Sent\"}"; - } -} - -void HandleInvalidRoute(HTTPReply& reply) { - reply.status = eHTTPStatusCode::NOT_FOUND; - reply.message = "{\"error\":\"Invalid Route\"}"; -} - -void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_msg) { - HTTPReply reply; - - if (!http_msg) { - reply.status = eHTTPStatusCode::BAD_REQUEST; - reply.message = "{\"error\":\"Invalid Request\"}"; - } else if (ValidateAuthentication(http_msg)) { - - // convert method from cstring to std string - std::string method_string(http_msg->method.buf, http_msg->method.len); - // get mehtod from mg to enum - const eHTTPMethod method = magic_enum::enum_cast(method_string).value_or(eHTTPMethod::INVALID); - - // convert uri from cstring to std string - std::string uri(http_msg->uri.buf, http_msg->uri.len); - std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower); - - // convert body from cstring to std string - std::string body(http_msg->body.buf, http_msg->body.len); - - - const auto routeItr = Routes.find({ method, uri }); - - if (routeItr != Routes.end()) { - const auto& [_, route] = *routeItr; - route.handle(reply, body); - } else HandleInvalidRoute(reply); - } else { - reply.status = eHTTPStatusCode::UNAUTHORIZED; - reply.message = "{\"error\":\"Unauthorized\"}"; - } - mg_http_reply(connection, static_cast(reply.status), json_content_type, reply.message.c_str()); -} - - -void HandleRequests(mg_connection* connection, int request, void* request_data) { - switch (request) { - case MG_EV_HTTP_MSG: - HandleHTTPMessage(connection, static_cast(request_data)); - break; - default: - break; - } -} - -void ChatWebAPI::RegisterHTTPRoutes(WebAPIHTTPRoute route) { - auto [_, success] = Routes.try_emplace({ route.method, route.path }, route); - if (!success) { - LOG_DEBUG("Failed to register route %s", route.path.c_str()); - } else { - LOG_DEBUG("Registered route %s", route.path.c_str()); - } -} - -ChatWebAPI::ChatWebAPI() { - mg_log_set(MG_LL_NONE); - mg_mgr_init(&mgr); // Initialize event manager -} - -ChatWebAPI::~ChatWebAPI() { - mg_mgr_free(&mgr); -} - -bool ChatWebAPI::Startup() { - // Make listen address - // std::string listen_ip = Game::config->GetValue("web_server_listen_ip"); - // if (listen_ip == "localhost") listen_ip = "127.0.0.1"; - - const std::string& listen_port = Game::config->GetValue("web_server_listen_port"); - // const std::string& listen_address = "http://" + listen_ip + ":" + listen_port; - const std::string& listen_address = "http://localhost:" + listen_port; - LOG("Starting web server on %s", listen_address.c_str()); - - // Create HTTP listener - if (!mg_http_listen(&mgr, listen_address.c_str(), HandleRequests, NULL)) { - LOG("Failed to create web server listener on %s", listen_port.c_str()); - return false; - } - - // Register routes - - // API v1 routes - std::string v1_route = "/api/v1/"; - RegisterHTTPRoutes({ - .path = v1_route + "players", - .method = eHTTPMethod::GET, - .handle = HandlePlayersRequest - }); - - RegisterHTTPRoutes({ - .path = v1_route + "teams", - .method = eHTTPMethod::GET, - .handle = HandleTeamsRequest - }); - - RegisterHTTPRoutes({ - .path = v1_route + "announce", - .method = eHTTPMethod::POST, - .handle = HandleAnnounceRequest - }); - return true; -} - -void ChatWebAPI::ReceiveRequests() { - mg_mgr_poll(&mgr, 15); -} - -#ifdef DARKFLAME_PLATFORM_WIN32 -#pragma pop_macro("DELETE") -#endif diff --git a/dChatServer/ChatWebAPI.h b/dChatServer/ChatWebAPI.h deleted file mode 100644 index c5626298..00000000 --- a/dChatServer/ChatWebAPI.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifndef __CHATWEBAPI_H__ -#define __CHATWEBAPI_H__ -#include -#include - -#include "mongoose.h" -#include "eHTTPStatusCode.h" - -enum class eHTTPMethod; - -typedef struct mg_mgr mg_mgr; - -struct HTTPReply { - eHTTPStatusCode status = eHTTPStatusCode::NOT_FOUND; - std::string message = "{\"error\":\"Not Found\"}"; -}; - -struct WebAPIHTTPRoute { - std::string path; - eHTTPMethod method; - std::function handle; -}; - -class ChatWebAPI { -public: - ChatWebAPI(); - ~ChatWebAPI(); - void ReceiveRequests(); - void RegisterHTTPRoutes(WebAPIHTTPRoute route); - bool Startup(); -private: - mg_mgr mgr; - -}; - -#endif // __CHATWEBAPI_H__ diff --git a/dChatServer/PlayerContainer.cpp b/dChatServer/PlayerContainer.cpp index ec53fa32..fac6501d 100644 --- a/dChatServer/PlayerContainer.cpp +++ b/dChatServer/PlayerContainer.cpp @@ -12,6 +12,7 @@ #include "ChatPackets.h" #include "dConfig.h" #include "MessageType/Chat.h" +#include "ChatWeb.h" #include "TeamContainer.h" void PlayerContainer::Initialize() { @@ -59,8 +60,9 @@ void PlayerContainer::InsertPlayer(Packet* packet) { m_PlayerCount++; LOG("Added user: %s (%llu), zone: %i", data.playerName.c_str(), data.playerID, data.zoneID.GetMapID()); + ChatWeb::SendWSPlayerUpdate(data, isLogin ? eActivityType::PlayerLoggedIn : eActivityType::PlayerChangedZone); - Database::Get()->UpdateActivityLog(data.playerID, eActivityType::PlayerLoggedIn, data.zoneID.GetMapID()); + Database::Get()->UpdateActivityLog(data.playerID, isLogin ? eActivityType::PlayerLoggedIn : eActivityType::PlayerChangedZone, data.zoneID.GetMapID()); m_PlayersToRemove.erase(playerId); } @@ -114,6 +116,8 @@ void PlayerContainer::RemovePlayer(const LWOOBJID playerID) { } } + ChatWeb::SendWSPlayerUpdate(player, eActivityType::PlayerLoggedOut); + m_PlayerCount--; LOG("Removed user: %llu", playerID); m_Players.erase(playerID); diff --git a/dCommon/CMakeLists.txt b/dCommon/CMakeLists.txt index 74432e0f..067e3c1c 100644 --- a/dCommon/CMakeLists.txt +++ b/dCommon/CMakeLists.txt @@ -16,6 +16,7 @@ set(DCOMMON_SOURCES "BrickByBrickFix.cpp" "BinaryPathFinder.cpp" "FdbToSqlite.cpp" + "JSONUtils.cpp" "TinyXmlUtils.cpp" "Sd0.cpp" "Lxfml.cpp" diff --git a/dCommon/JSONUtils.cpp b/dCommon/JSONUtils.cpp new file mode 100644 index 00000000..a1e1fddf --- /dev/null +++ b/dCommon/JSONUtils.cpp @@ -0,0 +1,17 @@ +#include "JSONUtils.h" +#include "json.hpp" + +using json = nlohmann::json; + +std::string JSONUtils::CheckRequiredData(const json& data, const std::vector& requiredData) { + json check; + check["error"] = json::array(); + for (const auto& required : requiredData) { + if (!data.contains(required)) { + check["error"].push_back("Missing Parameter: " + required); + } else if (data[required] == "") { + check["error"].push_back("Empty Parameter: " + required); + } + } + return check["error"].empty() ? "" : check.dump(); +} diff --git a/dCommon/JSONUtils.h b/dCommon/JSONUtils.h new file mode 100644 index 00000000..fbf9cf4f --- /dev/null +++ b/dCommon/JSONUtils.h @@ -0,0 +1,11 @@ +#ifndef _JSONUTILS_H_ +#define _JSONUTILS_H_ + +#include "json_fwd.hpp" + +namespace JSONUtils { + // check required fields in json data + std::string CheckRequiredData(const nlohmann::json& data, const std::vector& requiredData); +} + +#endif // _JSONUTILS_H_ diff --git a/dCommon/dEnums/eHTTPMethod.h b/dCommon/dEnums/eHTTPMethod.h index 8fd01379..fed86eb3 100644 --- a/dCommon/dEnums/eHTTPMethod.h +++ b/dCommon/dEnums/eHTTPMethod.h @@ -1,6 +1,8 @@ #ifndef __EHTTPMETHODS__H__ #define __EHTTPMETHODS__H__ +#include "dPlatforms.h" + #ifdef DARKFLAME_PLATFORM_WIN32 #pragma push_macro("DELETE") #undef DELETE diff --git a/dDatabase/GameDatabase/ITables/IActivityLog.h b/dDatabase/GameDatabase/ITables/IActivityLog.h index a67b61a4..10a97e97 100644 --- a/dDatabase/GameDatabase/ITables/IActivityLog.h +++ b/dDatabase/GameDatabase/ITables/IActivityLog.h @@ -8,6 +8,7 @@ enum class eActivityType : uint32_t { PlayerLoggedIn, PlayerLoggedOut, + PlayerChangedZone }; class IActivityLog { diff --git a/dNet/BitStreamUtils.h b/dNet/BitStreamUtils.h index d9e9bc9b..b9fdae42 100644 --- a/dNet/BitStreamUtils.h +++ b/dNet/BitStreamUtils.h @@ -61,6 +61,7 @@ struct LUBitStream { void WriteHeader(RakNet::BitStream& bitStream) const; bool ReadHeader(RakNet::BitStream& bitStream); void Send(const SystemAddress& sysAddr) const; + void Broadcast() const { Send(UNASSIGNED_SYSTEM_ADDRESS); }; virtual void Serialize(RakNet::BitStream& bitStream) const {} virtual bool Deserialize(RakNet::BitStream& bitStream) { return true; } diff --git a/dNet/ChatPackets.cpp b/dNet/ChatPackets.cpp index 087a3214..470374dc 100644 --- a/dNet/ChatPackets.cpp +++ b/dNet/ChatPackets.cpp @@ -98,14 +98,13 @@ void ChatPackets::SendMessageFail(const SystemAddress& sysAddr) { SEND_PACKET; } -void ChatPackets::Announcement::Send() { - CBITSTREAM; - BitStreamUtils::WriteHeader(bitStream, eConnectionType::CHAT, MessageType::Chat::GM_ANNOUNCE); - bitStream.Write(title.size()); - bitStream.Write(title); - bitStream.Write(message.size()); - bitStream.Write(message); - SEND_PACKET_BROADCAST; +namespace ChatPackets { + void Announcement::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(title.size()); + bitStream.Write(title); + bitStream.Write(message.size()); + bitStream.Write(message); + } } void ChatPackets::AchievementNotify::Serialize(RakNet::BitStream& bitstream) const { diff --git a/dNet/ChatPackets.h b/dNet/ChatPackets.h index a0cfbc9a..58bb9a1d 100644 --- a/dNet/ChatPackets.h +++ b/dNet/ChatPackets.h @@ -30,10 +30,12 @@ struct FindPlayerRequest{ namespace ChatPackets { - struct Announcement { + struct Announcement : public LUBitStream { std::string title; std::string message; - void Send(); + + Announcement() : LUBitStream(eConnectionType::CHAT, MessageType::Chat::GM_ANNOUNCE) {}; + virtual void Serialize(RakNet::BitStream& bitStream) const override; }; struct AchievementNotify : public LUBitStream { diff --git a/dNet/WorldPackets.cpp b/dNet/WorldPackets.cpp index ddd16c3e..512f2265 100644 --- a/dNet/WorldPackets.cpp +++ b/dNet/WorldPackets.cpp @@ -134,7 +134,7 @@ void WorldPackets::SendCreateCharacter(const SystemAddress& sysAddr, int64_t rep LOG("Sent CreateCharacter for ID: %llu", player); } -void WorldPackets::SendChatModerationResponse(const SystemAddress& sysAddr, bool requestAccepted, uint32_t requestID, const std::string& receiver, std::vector> unacceptedItems) { +void WorldPackets::SendChatModerationResponse(const SystemAddress& sysAddr, bool requestAccepted, uint32_t requestID, const std::string& receiver, std::set> unacceptedItems) { CBITSTREAM; BitStreamUtils::WriteHeader(bitStream, eConnectionType::CLIENT, MessageType::Client::CHAT_MODERATION_STRING); diff --git a/dNet/WorldPackets.h b/dNet/WorldPackets.h index 0081623e..0da98604 100644 --- a/dNet/WorldPackets.h +++ b/dNet/WorldPackets.h @@ -32,7 +32,7 @@ namespace WorldPackets { void SendTransferToWorld(const SystemAddress& sysAddr, const std::string& serverIP, uint32_t serverPort, bool mythranShift); void SendServerState(const SystemAddress& sysAddr); void SendCreateCharacter(const SystemAddress& sysAddr, int64_t reputation, LWOOBJID player, const std::string& xmlData, const std::u16string& username, eGameMasterLevel gm); - void SendChatModerationResponse(const SystemAddress& sysAddr, bool requestAccepted, uint32_t requestID, const std::string& receiver, std::vector> unacceptedItems); + void SendChatModerationResponse(const SystemAddress& sysAddr, bool requestAccepted, uint32_t requestID, const std::string& receiver, std::set> unacceptedItems); void SendGMLevelChange(const SystemAddress& sysAddr, bool success, eGameMasterLevel highestLevel, eGameMasterLevel prevLevel, eGameMasterLevel newLevel); void SendHTTPMonitorInfo(const SystemAddress& sysAddr, const HTTPMonitorInfo& info); void SendDebugOuput(const SystemAddress& sysAddr, const std::string& data); diff --git a/dWeb/CMakeLists.txt b/dWeb/CMakeLists.txt new file mode 100644 index 00000000..ae681a4e --- /dev/null +++ b/dWeb/CMakeLists.txt @@ -0,0 +1,7 @@ +set(DWEB_SOURCES + "Web.cpp") + +add_library(dWeb STATIC ${DWEB_SOURCES}) + +target_include_directories(dWeb PUBLIC ".") +target_link_libraries(dWeb dCommon mongoose) diff --git a/dWeb/Web.cpp b/dWeb/Web.cpp new file mode 100644 index 00000000..abf9bd36 --- /dev/null +++ b/dWeb/Web.cpp @@ -0,0 +1,301 @@ +#include "Web.h" +#include "Game.h" +#include "magic_enum.hpp" +#include "json.hpp" +#include "Logger.h" +#include "eHTTPMethod.h" +#include "GeneralUtils.h" +#include "JSONUtils.h" +#include + +namespace Game { + Web web; +} + +namespace { + const char* jsonContentType = "Content-Type: application/json\r\n"; + const std::string wsSubscribed = "{\"status\":\"subscribed\"}"; + const std::string wsUnsubscribed = "{\"status\":\"unsubscribed\"}"; + std::map, HTTPRoute> g_HTTPRoutes; + std::map g_WSEvents; + std::vector g_WSSubscriptions; +} + +using json = nlohmann::json; + +bool ValidateAuthentication(const mg_http_message* http_msg) { + // TO DO: This is just a placeholder for now + // use tokens or something at a later point if we want to implement authentication + // bit using the listen bind address to limit external access is good enough to start with + return true; +} + +void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_msg) { + if (g_HTTPRoutes.empty()) return; + + HTTPReply reply; + + if (!http_msg) { + reply.status = eHTTPStatusCode::BAD_REQUEST; + reply.message = "{\"error\":\"Invalid Request\"}"; + } else if (ValidateAuthentication(http_msg)) { + + // convert method from cstring to std string + std::string method_string(http_msg->method.buf, http_msg->method.len); + // get method from mg to enum + const eHTTPMethod method = magic_enum::enum_cast(method_string).value_or(eHTTPMethod::INVALID); + + // convert uri from cstring to std string + std::string uri(http_msg->uri.buf, http_msg->uri.len); + std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower); + + // convert body from cstring to std string + std::string body(http_msg->body.buf, http_msg->body.len); + + // Special case for websocket + if (uri == "/ws" && method == eHTTPMethod::GET) { + mg_ws_upgrade(connection, const_cast(http_msg), NULL); + LOG_DEBUG("Upgraded connection to websocket: %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port); + // return cause they are now a websocket + return; + } + + // Handle HTTP request + const auto routeItr = g_HTTPRoutes.find({method, uri}); + if (routeItr != g_HTTPRoutes.end()) { + const auto& [_, route] = *routeItr; + route.handle(reply, body); + } else { + reply.status = eHTTPStatusCode::NOT_FOUND; + reply.message = "{\"error\":\"Not Found\"}"; + } + } else { + reply.status = eHTTPStatusCode::UNAUTHORIZED; + reply.message = "{\"error\":\"Unauthorized\"}"; + } + mg_http_reply(connection, static_cast(reply.status), jsonContentType, reply.message.c_str()); +} + + +void HandleWSMessage(mg_connection* connection, const mg_ws_message* ws_msg) { + if (!ws_msg) { + LOG_DEBUG("Received invalid websocket message"); + return; + } else { + LOG_DEBUG("Received websocket message: %.*s", static_cast(ws_msg->data.len), ws_msg->data.buf); + auto data = GeneralUtils::TryParse(std::string(ws_msg->data.buf, ws_msg->data.len)); + if (data) { + const auto& good_data = data.value(); + auto check = JSONUtils::CheckRequiredData(good_data, { "event" }); + if (!check.empty()) { + LOG_DEBUG("Received invalid websocket message: %s", check.c_str()); + } else { + const auto event = good_data["event"].get(); + const auto eventItr = g_WSEvents.find(event); + if (eventItr != g_WSEvents.end()) { + const auto& [_, event] = *eventItr; + event.handle(connection, good_data); + } else { + LOG_DEBUG("Received invalid websocket event: %s", event.c_str()); + } + } + } else { + LOG_DEBUG("Received invalid websocket message: %.*s", static_cast(ws_msg->data.len), ws_msg->data.buf); + } + } +} + +// Handle websocket connection subscribing to an event +void HandleWSSubscribe(mg_connection* connection, json data) { + auto check = JSONUtils::CheckRequiredData(data, { "subscription" }); + if (!check.empty()) { + LOG_DEBUG("Received invalid websocket message: %s", check.c_str()); + } else { + const auto subscription = data["subscription"].get(); + // check subscription vector + auto subItr = std::ranges::find(g_WSSubscriptions, subscription); + if (subItr != g_WSSubscriptions.end()) { + // get index of subscription + auto index = std::distance(g_WSSubscriptions.begin(), subItr); + connection->data[index] = SubscriptionStatus::SUBSCRIBED; + // send subscribe message + mg_ws_send(connection, wsSubscribed.c_str(), wsSubscribed.size(), WEBSOCKET_OP_TEXT); + LOG_DEBUG("subscription %s subscribed", subscription.c_str()); + } + } +} + +// Handle websocket connection unsubscribing from an event +void HandleWSUnsubscribe(mg_connection* connection, json data) { + auto check = JSONUtils::CheckRequiredData(data, { "subscription" }); + if (!check.empty()) { + LOG_DEBUG("Received invalid websocket message: %s", check.c_str()); + } else { + const auto subscription = data["subscription"].get(); + // check subscription vector + auto subItr = std::ranges::find(g_WSSubscriptions, subscription); + if (subItr != g_WSSubscriptions.end()) { + // get index of subscription + auto index = std::distance(g_WSSubscriptions.begin(), subItr); + connection->data[index] = SubscriptionStatus::UNSUBSCRIBED; + // send unsubscribe message + mg_ws_send(connection, wsUnsubscribed.c_str(), wsUnsubscribed.size(), WEBSOCKET_OP_TEXT); + LOG_DEBUG("subscription %s unsubscribed", subscription.c_str()); + } + } +} + +void HandleWSGetSubscriptions(mg_connection* connection, json data) { + // list subscribed and non subscribed subscriptions + json response; + // check subscription vector + for (const auto& sub : g_WSSubscriptions) { + auto subItr = std::ranges::find(g_WSSubscriptions, sub); + if (subItr != g_WSSubscriptions.end()) { + // get index of subscription + auto index = std::distance(g_WSSubscriptions.begin(), subItr); + if (connection->data[index] == SubscriptionStatus::SUBSCRIBED) { + response["subscribed"].push_back(sub); + } else { + response["unsubscribed"].push_back(sub); + } + } + } + mg_ws_send(connection, response.dump().c_str(), response.dump().size(), WEBSOCKET_OP_TEXT); +} + +void HandleMessages(mg_connection* connection, int message, void* message_data) { + if (!Game::web.IsEnabled()) return; + switch (message) { + case MG_EV_HTTP_MSG: + HandleHTTPMessage(connection, static_cast(message_data)); + break; + case MG_EV_WS_MSG: + HandleWSMessage(connection, static_cast(message_data)); + break; + default: + break; + } +} + +// Redirect mongoose logs to our logger +static void DLOG(char ch, void *param) { + static char buf[256]{}; + static size_t len{}; + if (ch != '\n') buf[len++] = ch; // we provide the newline in our logger + if (ch == '\n' || len >= sizeof(buf)) { + LOG_DEBUG("%.*s", static_cast(len), buf); + len = 0; + } +} + +void Web::RegisterHTTPRoute(HTTPRoute route) { + if (!Game::web.enabled) { + LOG_DEBUG("Failed to register HTTP route %s: web server not enabled", route.path.c_str()); + return; + } + + auto [_, success] = g_HTTPRoutes.try_emplace({ route.method, route.path }, route); + if (!success) { + LOG_DEBUG("Failed to register HTTP route %s", route.path.c_str()); + } else { + LOG_DEBUG("Registered HTTP route %s", route.path.c_str()); + } +} + +void Web::RegisterWSEvent(WSEvent event) { + if (!Game::web.enabled) { + LOG_DEBUG("Failed to register WS event %s: web server not enabled", event.name.c_str()); + return; + } + + auto [_, success] = g_WSEvents.try_emplace(event.name, event); + if (!success) { + LOG_DEBUG("Failed to register WS event %s", event.name.c_str()); + } else { + LOG_DEBUG("Registered WS event %s", event.name.c_str()); + } +} + +void Web::RegisterWSSubscription(const std::string& subscription) { + if (!Game::web.enabled) { + LOG_DEBUG("Failed to register WS subscription %s: web server not enabled", subscription.c_str()); + return; + } + + // check that subsction is not already in the vector + auto subItr = std::ranges::find(g_WSSubscriptions, subscription); + if (subItr != g_WSSubscriptions.end()) { + LOG_DEBUG("Failed to register WS subscription %s: duplicate", subscription.c_str()); + } else { + LOG_DEBUG("Registered WS subscription %s", subscription.c_str()); + g_WSSubscriptions.push_back(subscription); + } +} + +Web::Web() { + mg_log_set_fn(DLOG, NULL); // Redirect logs to our logger + mg_log_set(MG_LL_DEBUG); + mg_mgr_init(&mgr); // Initialize event manager +} + +Web::~Web() { + mg_mgr_free(&mgr); +} + +bool Web::Startup(const std::string& listen_ip, const uint32_t listen_port) { + + // Make listen address + const std::string listen_address = "http://" + listen_ip + ":" + std::to_string(listen_port); + LOG("Starting web server on %s", listen_address.c_str()); + + // Create HTTP listener + if (!mg_http_listen(&mgr, listen_address.c_str(), HandleMessages, NULL)) { + LOG("Failed to create web server listener on %s", listen_address.c_str()); + return false; + } + + // Set enabled flag + Game::web.enabled = true; + + // Core WebSocket Events + Game::web.RegisterWSEvent({ + .name = "subscribe", + .handle = HandleWSSubscribe + }); + + Game::web.RegisterWSEvent({ + .name = "unsubscribe", + .handle = HandleWSUnsubscribe + }); + + Game::web.RegisterWSEvent({ + .name = "getSubscriptions", + .handle = HandleWSGetSubscriptions + }); + + return true; +} + +void Web::ReceiveRequests() { + mg_mgr_poll(&mgr, 15); +} + +void Web::SendWSMessage(const std::string subscription, json& data) { + if (!Game::web.enabled) return; // don't attempt to send if web is not enabled + + // find subscription + auto subItr = std::ranges::find(g_WSSubscriptions, subscription); + if (subItr == g_WSSubscriptions.end()) { + LOG_DEBUG("Failed to send WS message: subscription %s not found", subscription.c_str()); + return; + } + // tell it the event type + data["event"] = subscription; + auto index = std::distance(g_WSSubscriptions.begin(), subItr); + for (auto *wc = Game::web.mgr.conns; wc != NULL; wc = wc->next) { + if (wc->is_websocket && wc->data[index] == SubscriptionStatus::SUBSCRIBED) { + mg_ws_send(wc, data.dump().c_str(), data.dump().size(), WEBSOCKET_OP_TEXT); + } + } +} diff --git a/dWeb/Web.h b/dWeb/Web.h new file mode 100644 index 00000000..1752f755 --- /dev/null +++ b/dWeb/Web.h @@ -0,0 +1,82 @@ +#ifndef __WEB_H__ +#define __WEB_H__ + +#include +#include +#include +#include "mongoose.h" +#include "json_fwd.hpp" +#include "eHTTPStatusCode.h" + +// Forward declarations for game namespace +// so that we can access the data anywhere +class Web; +namespace Game { + extern Web web; +} + +enum class eHTTPMethod; + +// Forward declaration for mongoose manager +typedef struct mg_mgr mg_mgr; + +// For passing HTTP messages between functions +struct HTTPReply { + eHTTPStatusCode status = eHTTPStatusCode::NOT_FOUND; + std::string message = "{\"error\":\"Not Found\"}"; +}; + +// HTTP route structure +// This structure is used to register HTTP routes +// with the server. Each route has a path, method, and a handler function +// that will be called when the route is matched. +struct HTTPRoute { + std::string path; + eHTTPMethod method; + std::function handle; +}; + +// WebSocket event structure +// This structure is used to register WebSocket events +// with the server. Each event has a name and a handler function +// that will be called when the event is triggered. +struct WSEvent { + std::string name; + std::function handle; +}; + +// Subscription status for WebSocket clients +enum SubscriptionStatus { + UNSUBSCRIBED = 0, + SUBSCRIBED = 1 +}; + +class Web { +public: + // Constructor + Web(); + // Destructor + ~Web(); + // Handle incoming messages + void ReceiveRequests(); + // Start the web server + // Returns true if the server started successfully + bool Startup(const std::string& listen_ip, const uint32_t listen_port); + // Register HTTP route to be handled by the server + void RegisterHTTPRoute(HTTPRoute route); + // Register WebSocket event to be handled by the server + void RegisterWSEvent(WSEvent event); + // Register WebSocket subscription to be handled by the server + void RegisterWSSubscription(const std::string& subscription); + // Returns if the web server is enabled + bool IsEnabled() const { return enabled; }; + // Send a message to all connected WebSocket clients that are subscribed to the given topic + void static SendWSMessage(std::string sub, nlohmann::json& message); +private: + // mongoose manager + mg_mgr mgr; + // If the web server is enabled + bool enabled = false; +}; + +#endif // !__WEB_H__ diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index d2691e1b..0f433493 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -1327,7 +1327,7 @@ void HandlePacket(Packet* packet) { } } - std::vector> segments = Game::chatFilter->IsSentenceOkay(request.message, entity->GetGMLevel(), !(isBestFriend && request.chatLevel == 1)); + const auto segments = Game::chatFilter->IsSentenceOkay(request.message, entity->GetGMLevel(), !(isBestFriend && request.chatLevel == 1)); bool bAllClean = segments.empty(); diff --git a/docs/ChatWSAPI.yaml b/docs/ChatWSAPI.yaml new file mode 100644 index 00000000..d344bc66 --- /dev/null +++ b/docs/ChatWSAPI.yaml @@ -0,0 +1,131 @@ +asyncapi: 3.0.0 +info: + title: Darkflame Chat Server WebSocket API + version: 1.0.0 + description: API documentation for Darkflame Chat Server WebSocket endpoints +servers: + production: + host: 'localhost:2005' + pathname: /ws + protocol: http + description: Address of the websocket for the chat server +channels: + subscribe: + address: subscribe + messages: + subscribe.message: + title: Subscribe + contentType: application/json + payload: + $ref: '#/components/schemas/Subscription' + unsubscribe: + address: unsubscribe + messages: + unsubscribe.message: + title: Unsubscribe + contentType: application/json + payload: + $ref: '#/components/schemas/Subscription' + getSubscriptions: + address: getSubscriptions + messages: + getSubscriptions.message: + title: Get Subscriptions + contentType: application/json + payload: + type: object + properties: + subscriptions: + type: array + items: + type: string + example: player + player: + address: player + messages: + player.message: + title: Player + contentType: application/json + payload: + $ref: '#/components/schemas/PlayerUpdate' +operations: + subscribe: + action: receive + channel: + $ref: '#/channels/subscribe' + summary: Subscribe to an event + messages: + - $ref: '#/channels/subscribe/messages/subscribe.message' + unsubscribe: + action: receive + channel: + $ref: '#/channels/unsubscribe' + summary: Unsubscribe from an event + messages: + - $ref: '#/channels/unsubscribe/messages/unsubscribe.message' + getSubscriptions: + action: receive + channel: + $ref: '#/channels/getSubscriptions' + summary: Get the list of subscriptions + messages: + - $ref: '#/channels/getSubscriptions/messages/getSubscriptions.message' + player: + action: send + channel: + $ref: '#/channels/player' + summary: Player event + messages: + - $ref: '#/channels/player/messages/player.message' +components: + schemas: + PlayerUpdate: + type: object + properties: + player_data: + $ref: '#/components/schemas/Player' + update_type: + type: string + example: JOIN + Subscription: + type: object + required: + - subscription + properties: + subscription: + type: string + example: player + Player: + type: object + properties: + id: + type: integer + format: int64 + example: 1152921508901824000 + gm_level: + type: integer + format: uint8 + example: 0 + name: + type: string + example: thisisatestname + muted: + type: boolean + example: false + zone_id: + $ref: '#/components/schemas/ZoneID' + ZoneID: + type: object + properties: + map_id: + type: integer + format: uint16 + example: 1200 + instance_id: + type: integer + format: uint16 + example: 2 + clone_id: + type: integer + format: uint32 + example: 0