diff --git a/ModExamples/24Websocket/24Websocket.csproj b/ModExamples/24Websocket/24Websocket.csproj new file mode 100644 index 00000000..009a01d3 --- /dev/null +++ b/ModExamples/24Websocket/24Websocket.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + _24Websocket + enable + enable + Library + + + + + + ..\TempReferences\Core.dll + false + false + false + + + ..\TempReferences\SptCommon.dll + false + false + false + + + ..\TempReferences\SptDependencyInjection.dll + false + false + false + + + + + true + PreserveNewest + PreserveNewest + + + + diff --git a/ModExamples/24Websocket/WebsocketConnectionHandler.cs b/ModExamples/24Websocket/WebsocketConnectionHandler.cs new file mode 100644 index 00000000..b22c92b5 --- /dev/null +++ b/ModExamples/24Websocket/WebsocketConnectionHandler.cs @@ -0,0 +1,194 @@ +using System.Net.WebSockets; +using System.Text; +using Core.Helpers; +using Core.Models.Eft.Ws; +using Core.Models.Utils; +using Core.Servers.Ws; +using Core.Servers.Ws.Message; +using Core.Utils; +using SptCommon.Annotations; +using LogLevel = Core.Models.Spt.Logging.LogLevel; + +namespace _24Websocket; +// TODO: this is basically a copy of what we do, what is NEEDED of each method and add comments +[Injectable(InjectionType = InjectionType.Singleton)] +public class WebsocketConnectionHandler : IWebSocketConnectionHandler +{ + private readonly ISptLogger _logger; + private readonly ProfileHelper _profileHelper; + private readonly JsonUtil _jsonUtil; + private readonly IEnumerable _messageHandlers; + + protected WsPing _defaultNotification = new(); + protected Lock _lockObject = new(); + protected Dictionary _receiveTasks = new(); + protected Dictionary _socketAliveTimers = new(); + + protected Dictionary _sockets = new(); + + public WebsocketConnectionHandler( + ISptLogger logger, + ProfileHelper profileHelper, + JsonUtil jsonUtil, + IEnumerable messageHandlers + ) + { + _logger = logger; + _profileHelper = profileHelper; + _jsonUtil = jsonUtil; + } + + public string GetHookUrl() + { + return "/custom/socket/"; + } + + public string GetSocketId() + { + return "My Custom WebSocket"; + } + + public Task OnConnection(WebSocket ws, HttpContext context) + { + return Task.Factory.StartNew( + () => + { + var splitUrl = context.Request.Path.Value.Split("/"); + var sessionID = splitUrl.Last(); + var playerProfile = _profileHelper.GetFullProfile(sessionID); + var playerInfoText = $"{playerProfile.ProfileInfo.Username} ({sessionID})"; + _logger.Info($"Custom web socket is now connected!: {playerInfoText}"); + + _sockets.Add(sessionID, ws); + + lock (_lockObject) + { + _receiveTasks.Add(sessionID, new CancellationTokenSource()); + var cancelToken = _receiveTasks[sessionID].Token; + Task.Factory.StartNew(_ => ReceiveTask(sessionID, ws, cancelToken), null, cancelToken); + } + + while (ws.State == WebSocketState.Open) + { + Thread.Sleep(1000); + } + + // Once the websocket dies, we dispose of it + lock (_lockObject) + { + if (_socketAliveTimers.TryGetValue(sessionID, out var timer)) + { + timer.Change(Timeout.Infinite, Timeout.Infinite); + _socketAliveTimers.Remove(sessionID); + } + + if (_sockets.ContainsKey(sessionID)) + { + _sockets.Remove(sessionID); + } + + if (_receiveTasks.TryGetValue(sessionID, out var receiveTask)) + { + receiveTask.CancelAsync().Wait(); + } + } + } + ); + } + + private void ReceiveTask(string sessionId, WebSocket ws, CancellationToken cancelToken) + { + List readBytes = new(); + while (ws.State == WebSocketState.Open) + { + try + { + if (cancelToken.IsCancellationRequested) + { + break; + } + + var isEndOfMessage = false; + while (!isEndOfMessage) + { + var buffer = new ArraySegment(new byte[1024 * 4]); + var readTask = ws.ReceiveAsync(buffer, cancelToken); + readTask.Wait(cancelToken); + readBytes.AddRange(buffer); + isEndOfMessage = readTask.Result.EndOfMessage; + } + + foreach (var sptWebSocketMessageHandler in _messageHandlers) + { + sptWebSocketMessageHandler.OnSptMessage(sessionId, ws, readBytes.ToArray()).Wait(); + } + } + catch (OperationCanceledException _) + { + _logger.Info("WebSocket disconnecting, receive task finalized..."); + } + catch (Exception _) + { + lock (_lockObject) + { + _sockets.Remove(sessionId); + _socketAliveTimers.Remove(sessionId); + _receiveTasks.Remove(sessionId); + var playerProfile = _profileHelper.GetFullProfile(sessionId); + var playerInfoText = $"{playerProfile.ProfileInfo.Username} ({sessionId})"; + _logger.Info($"[ws] player: {playerInfoText} has disconnected"); + } + + ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed connection", CancellationToken.None); + } + finally + { + readBytes.Clear(); + } + } + } + + public bool IsWebSocketConnected(string sessionId) + { + return _sockets.TryGetValue(sessionId, out var socket) && socket.State == WebSocketState.Open; + } + + public void SendMessage(string sessionID, WsNotificationEvent output) + { + try + { + if (IsWebSocketConnected(sessionID)) + { + var ws = GetSessionWebSocket(sessionID); + + var sendTask = ws.SendAsync( + Encoding.UTF8.GetBytes(_jsonUtil.Serialize(output, output.GetType())), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + sendTask.Wait(); + if (_logger.IsLogEnabled(LogLevel.Debug)) + { + _logger.Debug("Sent Message"); + } + } + else + { + if (_logger.IsLogEnabled(LogLevel.Debug)) + { + _logger.Debug("Couldnt send Message"); + } + } + } + catch (Exception err) + { + _logger.Error("message failed with error"); + } + } + + public WebSocket GetSessionWebSocket(string sessionID) + { + return _sockets[sessionID]; + } +} diff --git a/ModExamples/24Websocket/WebsocketMessageHandler.cs b/ModExamples/24Websocket/WebsocketMessageHandler.cs new file mode 100644 index 00000000..8b60d19e --- /dev/null +++ b/ModExamples/24Websocket/WebsocketMessageHandler.cs @@ -0,0 +1,23 @@ +using System.Net.WebSockets; +using Core.Models.Utils; +using Core.Servers.Ws.Message; + +namespace _24Websocket; + +public class WebsocketMessageHandler : ISptWebSocketMessageHandler +{ + private readonly ISptLogger _logger; + + public WebsocketMessageHandler( + ISptLogger logger + ) + { + _logger = logger; + } + + public Task OnSptMessage(string sessionID, WebSocket client, byte[] rawData) + { + _logger.Info($"Custom SPT WebSocket Message handler received a message for {sessionID}: {rawData.ToString()}"); + return Task.CompletedTask; + } +} diff --git a/ModExamples/24Websocket/package.json b/ModExamples/24Websocket/package.json new file mode 100644 index 00000000..8fb13a94 --- /dev/null +++ b/ModExamples/24Websocket/package.json @@ -0,0 +1,13 @@ +{ + "Name": "24Websocket", + "Version": "1.0.0", + "SptVersion": "~4.0", + "LoadBefore": [], + "LoadAfter": [], + "IncompatibileMods": [], + "Url": "https://github.com/sp-tarkov/server-csharp/tree/develop/ExampleMods/Mods", + "IsBundleMod": false, + "Author": "SPT", + "Contributors": [], + "Licence": "MIT" +} diff --git a/ModExamples/ModExamples.sln b/ModExamples/ModExamples.sln index d5ee619c..430fa4dc 100644 --- a/ModExamples/ModExamples.sln +++ b/ModExamples/ModExamples.sln @@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "22CustomSptCommand", "22Cus EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "23CustomAbstractChatBot", "23CustomAbstractChatBot\23CustomAbstractChatBot.csproj", "{B97A7EE2-04DD-4690-866E-9179F5E01908}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "24Websocket", "24Websocket\24Websocket.csproj", "{94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -141,6 +143,10 @@ Global {B97A7EE2-04DD-4690-866E-9179F5E01908}.Debug|Any CPU.Build.0 = Debug|Any CPU {B97A7EE2-04DD-4690-866E-9179F5E01908}.Release|Any CPU.ActiveCfg = Release|Any CPU {B97A7EE2-04DD-4690-866E-9179F5E01908}.Release|Any CPU.Build.0 = Release|Any CPU + {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE